Coverage for src/lexigram/admin/media/models.py: 0%
58 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:18 +0800
1"""Media library data models — MediaItem and MediaPage."""
3from __future__ import annotations
5from dataclasses import dataclass, field
6from datetime import UTC, datetime
7from typing import Any
8import uuid
11@dataclass
12class MediaItem:
13 """Metadata record for a single media file.
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 """
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)
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 ""
58 @property
59 def is_image(self) -> bool:
60 """Return ``True`` for image MIME types."""
61 return self.mime_type.startswith("image/")
63 @property
64 def is_video(self) -> bool:
65 """Return ``True`` for video MIME types."""
66 return self.mime_type.startswith("video/")
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 )
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 }
101@dataclass
102class MediaPage:
103 """Paginated result from :meth:`MediaLibrary.list_folder`.
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 """
112 items: list[MediaItem]
113 page: int
114 page_size: int
115 total: int
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
124 return math.ceil(self.total / self.page_size)
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
131 @property
132 def has_prev(self) -> bool:
133 """Return ``True`` if this is not the first page."""
134 return self.page > 1
137__all__ = [
138 "MediaItem",
139 "MediaPage",
140]