Coverage for src/lexigram/admin/media/models.py: 98%

58 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Media library data models — MediaItem and MediaPage.""" 

2 

3from __future__ import annotations 

4 

5from dataclasses import dataclass, field 

6from datetime import UTC, datetime 

7from typing import Any 

8import uuid 

9 

10 

11@dataclass 

12class MediaItem: 

13 """Metadata record for a single media file. 

14 

15 Attributes: 

16 item_id: Unique library identifier. 

17 path: Storage path used to retrieve the file. 

18 filename: Original filename (human-friendly). 

19 mime_type: MIME type (e.g. ``"image/jpeg"``). 

20 size_bytes: File size in bytes. 

21 folder: Virtual folder (derived from *path* directory). 

22 uploaded_by: User identifier who uploaded the file. 

23 uploaded_at: UTC timestamp of upload. 

24 alt_text: Accessible alt text (for images). 

25 caption: Optional caption shown in UI. 

26 tags: Categorisation tags. 

27 width: Image width in pixels (``0`` if not an image). 

28 height: Image height in pixels (``0`` if not an image). 

29 is_deleted: Soft-delete flag. 

30 deleted_at: UTC timestamp of soft-delete. 

31 metadata: Arbitrary extra metadata. 

32 """ 

33 

34 item_id: str = field(default_factory=lambda: str(uuid.uuid4())[:12]) 

35 path: str = "" 

36 filename: str = "" 

37 mime_type: str = "" 

38 size_bytes: int = 0 

39 folder: str = "" 

40 uploaded_by: str = "" 

41 uploaded_at: datetime = field(default_factory=lambda: datetime.now(UTC)) 

42 alt_text: str = "" 

43 caption: str = "" 

44 tags: list[str] = field(default_factory=list) 

45 width: int = 0 

46 height: int = 0 

47 is_deleted: bool = False 

48 deleted_at: datetime | None = None 

49 metadata: dict[str, Any] = field(default_factory=dict) 

50 

51 @property 

52 def extension(self) -> str: 

53 """File extension (lowercase, without dot).""" 

54 if "." in self.filename: 

55 return self.filename.rsplit(".", 1)[1].lower() 

56 return "" 

57 

58 @property 

59 def is_image(self) -> bool: 

60 """Return ``True`` for image MIME types.""" 

61 return self.mime_type.startswith("image/") 

62 

63 @property 

64 def is_video(self) -> bool: 

65 """Return ``True`` for video MIME types.""" 

66 return self.mime_type.startswith("video/") 

67 

68 @property 

69 def is_document(self) -> bool: 

70 """Return ``True`` for document-like MIME types.""" 

71 return self.mime_type in ( 

72 "application/pdf", 

73 "application/msword", 

74 "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 

75 "text/plain", 

76 "text/csv", 

77 ) 

78 

79 def to_dict(self) -> dict[str, Any]: 

80 """Serialise to a JSON-safe dict.""" 

81 return { 

82 "item_id": self.item_id, 

83 "path": self.path, 

84 "filename": self.filename, 

85 "mime_type": self.mime_type, 

86 "size_bytes": self.size_bytes, 

87 "folder": self.folder, 

88 "uploaded_by": self.uploaded_by, 

89 "uploaded_at": self.uploaded_at.isoformat(), 

90 "alt_text": self.alt_text, 

91 "caption": self.caption, 

92 "tags": list(self.tags), 

93 "width": self.width, 

94 "height": self.height, 

95 "is_deleted": self.is_deleted, 

96 "extension": self.extension, 

97 "is_image": self.is_image, 

98 } 

99 

100 

101@dataclass 

102class MediaPage: 

103 """Paginated result from :meth:`MediaLibrary.list_folder`. 

104 

105 Attributes: 

106 items: Items on the current page. 

107 page: 1-based page number. 

108 page_size: Items per page requested. 

109 total: Total matching items (across all pages). 

110 """ 

111 

112 items: list[MediaItem] 

113 page: int 

114 page_size: int 

115 total: int 

116 

117 @property 

118 def total_pages(self) -> int: 

119 """Total number of pages.""" 

120 if self.page_size <= 0: 

121 return 1 

122 import math 

123 

124 return math.ceil(self.total / self.page_size) 

125 

126 @property 

127 def has_next(self) -> bool: 

128 """Return ``True`` if there are more pages after this one.""" 

129 return self.page < self.total_pages 

130 

131 @property 

132 def has_prev(self) -> bool: 

133 """Return ``True`` if this is not the first page.""" 

134 return self.page > 1 

135 

136 

137__all__ = [ 

138 "MediaItem", 

139 "MediaPage", 

140]