Skip to content

Commit 5b4c41d

Browse files
Merge pull request #1456 from rohan-pandeyy/feat/memory-to-album
Convert a memory into an album, plus album sorting and grid updates
2 parents 78e7dc3 + 3ae563a commit 5b4c41d

27 files changed

Lines changed: 1687 additions & 254 deletions

backend/app/database/albums.py

Lines changed: 149 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,61 @@
11
import sqlite3
2+
from typing import Any, List, Optional, Tuple, TypedDict
3+
24
import bcrypt
35
from app.config.settings import DATABASE_PATH
46
from app.database.connection import get_db_connection
57

68

9+
class AlbumRow(TypedDict):
10+
"""A row of the albums table, as the read helpers below return it."""
11+
12+
album_id: str
13+
album_name: str
14+
description: Optional[str]
15+
is_locked: bool
16+
password_hash: Optional[str]
17+
cover_image_path: Optional[str]
18+
created_at: Optional[str]
19+
updated_at: Optional[str]
20+
21+
22+
def _connect() -> sqlite3.Connection:
23+
conn = sqlite3.connect(DATABASE_PATH)
24+
# Ensure ON DELETE CASCADE and other FKs are enforced
25+
conn.execute("PRAGMA foreign_keys = ON")
26+
return conn
27+
28+
29+
# Named once so the SELECTs and the mapper below cannot drift apart.
30+
_ALBUM_COLUMNS = (
31+
"album_id, album_name, description, is_locked, "
32+
"password_hash, cover_image_path, created_at, updated_at"
33+
)
34+
35+
# Built once from the column list rather than interpolated at each call site.
36+
_SELECT_ALL_ALBUMS = f"SELECT {_ALBUM_COLUMNS} FROM albums ORDER BY rowid"
37+
_SELECT_ALBUM_BY_NAME = f"SELECT {_ALBUM_COLUMNS} FROM albums WHERE album_name = ?"
38+
_SELECT_ALBUM_BY_ID = f"SELECT {_ALBUM_COLUMNS} FROM albums WHERE album_id = ?"
39+
40+
41+
def _to_album_row(row: Tuple[Any, ...]) -> AlbumRow:
42+
"""Map a SELECT of _ALBUM_COLUMNS onto a named record."""
43+
return AlbumRow(
44+
album_id=row[0],
45+
album_name=row[1],
46+
description=row[2],
47+
is_locked=bool(row[3]),
48+
password_hash=row[4],
49+
cover_image_path=row[5],
50+
created_at=row[6],
51+
updated_at=row[7],
52+
)
53+
54+
755
def db_create_albums_table() -> None:
856
conn = None
957
try:
10-
conn = sqlite3.connect(DATABASE_PATH)
58+
conn = _connect()
1159
cursor = conn.cursor()
1260
cursor.execute(
1361
"""
@@ -17,18 +65,29 @@ def db_create_albums_table() -> None:
1765
description TEXT,
1866
is_locked BOOLEAN DEFAULT 0,
1967
password_hash TEXT,
20-
cover_image_path TEXT
68+
cover_image_path TEXT,
69+
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
70+
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
2171
)
2272
"""
2373
)
2474
# Shipped databases predate the is_hidden -> is_locked rename and the
25-
# cover_image_path column, and CREATE IF NOT EXISTS won't add either.
75+
# cover_image_path and created_at columns, and CREATE IF NOT EXISTS
76+
# won't add any of them.
2677
cursor.execute("PRAGMA table_info(albums)")
2778
columns = {row[1] for row in cursor.fetchall()}
2879
if "is_locked" not in columns and "is_hidden" in columns:
2980
cursor.execute("ALTER TABLE albums RENAME COLUMN is_hidden TO is_locked")
3081
if "cover_image_path" not in columns:
3182
cursor.execute("ALTER TABLE albums ADD COLUMN cover_image_path TEXT")
83+
if "created_at" not in columns:
84+
# No default: SQLite rejects a non-constant one on ALTER TABLE, and
85+
# stamping every existing album with the upgrade time would be a
86+
# date that never happened. They stay NULL and read as oldest,
87+
# which their insertion order already reflects.
88+
cursor.execute("ALTER TABLE albums ADD COLUMN created_at DATETIME")
89+
if "updated_at" not in columns:
90+
cursor.execute("ALTER TABLE albums ADD COLUMN updated_at DATETIME")
3291
conn.commit()
3392
finally:
3493
if conn is not None:
@@ -38,7 +97,7 @@ def db_create_albums_table() -> None:
3897
def db_create_album_images_table() -> None:
3998
conn = None
4099
try:
41-
conn = sqlite3.connect(DATABASE_PATH)
100+
conn = _connect()
42101
cursor = conn.cursor()
43102
cursor.execute(
44103
"""
@@ -63,44 +122,50 @@ def db_create_album_images_table() -> None:
63122
conn.close()
64123

65124

66-
def db_get_all_albums():
125+
def _touch_album(cursor: sqlite3.Cursor, album_id: str) -> None:
126+
"""
127+
Mark an album as changed just now.
128+
129+
Adding or removing photos counts: to a user, that is the album changing,
130+
not just its name or its lock.
131+
"""
132+
cursor.execute(
133+
"UPDATE albums SET updated_at = CURRENT_TIMESTAMP WHERE album_id = ?",
134+
(album_id,),
135+
)
136+
137+
138+
def db_get_all_albums() -> List[AlbumRow]:
67139
"""Get all albums (both locked and unlocked)."""
68-
conn = sqlite3.connect(DATABASE_PATH)
140+
conn = _connect()
69141
cursor = conn.cursor()
70142
try:
71-
cursor.execute(
72-
"SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums"
73-
)
74-
albums = cursor.fetchall()
75-
return albums
143+
# Insertion order, so albums predating created_at keep the order they
144+
# were made in rather than an arbitrary one.
145+
cursor.execute(_SELECT_ALL_ALBUMS)
146+
return [_to_album_row(row) for row in cursor.fetchall()]
76147
finally:
77148
conn.close()
78149

79150

80-
def db_get_album_by_name(name: str):
81-
conn = sqlite3.connect(DATABASE_PATH)
151+
def db_get_album_by_name(name: str) -> Optional[AlbumRow]:
152+
conn = _connect()
82153
cursor = conn.cursor()
83154
try:
84-
cursor.execute(
85-
"SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_name = ?",
86-
(name,),
87-
)
155+
cursor.execute(_SELECT_ALBUM_BY_NAME, (name,))
88156
album = cursor.fetchone()
89-
return album if album else None
157+
return _to_album_row(album) if album else None
90158
finally:
91159
conn.close()
92160

93161

94-
def db_get_album(album_id: str):
95-
conn = sqlite3.connect(DATABASE_PATH)
162+
def db_get_album(album_id: str) -> Optional[AlbumRow]:
163+
conn = _connect()
96164
cursor = conn.cursor()
97165
try:
98-
cursor.execute(
99-
"SELECT album_id, album_name, description, is_locked, password_hash, cover_image_path FROM albums WHERE album_id = ?",
100-
(album_id,),
101-
)
166+
cursor.execute(_SELECT_ALBUM_BY_ID, (album_id,))
102167
album = cursor.fetchone()
103-
return album if album else None
168+
return _to_album_row(album) if album else None
104169
finally:
105170
conn.close()
106171

@@ -110,20 +175,25 @@ def db_insert_album(
110175
album_name: str,
111176
description: str = "",
112177
is_locked: bool = False,
113-
password: str = None,
178+
password: Optional[str] = None,
114179
):
115-
conn = sqlite3.connect(DATABASE_PATH)
180+
conn = _connect()
116181
cursor = conn.cursor()
117182
try:
118183
password_hash = None
119184
if password:
120185
password_hash = bcrypt.hashpw(
121186
password.encode("utf-8"), bcrypt.gensalt()
122187
).decode("utf-8")
188+
# created_at is set here rather than left to the column default: a
189+
# database migrated with ALTER TABLE has no default to fall back on.
123190
cursor.execute(
124191
"""
125-
INSERT INTO albums (album_id, album_name, description, is_locked, password_hash)
126-
VALUES (?, ?, ?, ?, ?)
192+
INSERT INTO albums (
193+
album_id, album_name, description, is_locked,
194+
password_hash, created_at, updated_at
195+
)
196+
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
127197
""",
128198
(album_id, album_name, description, int(is_locked), password_hash),
129199
)
@@ -132,14 +202,45 @@ def db_insert_album(
132202
conn.close()
133203

134204

205+
def db_create_album_with_images(
206+
album_id: str, album_name: str, description: str, image_ids: list[str]
207+
) -> int:
208+
"""
209+
Create an album and link its images in a single transaction.
210+
211+
Both halves commit together, so a failed link never strands an empty album.
212+
Takes image ids rather than the id of whatever they came from, so the
213+
caller owns that choice. Returns the number of images actually linked.
214+
"""
215+
with get_db_connection() as conn:
216+
cursor = conn.cursor()
217+
cursor.execute(
218+
"""
219+
INSERT INTO albums (
220+
album_id, album_name, description, is_locked,
221+
password_hash, created_at, updated_at
222+
)
223+
VALUES (?, ?, ?, 0, NULL, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
224+
""",
225+
(album_id, album_name, description),
226+
)
227+
# Foreign keys are on for this connection, so an image id that no
228+
# longer exists rolls the album back with it rather than half-writing.
229+
cursor.executemany(
230+
"INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)",
231+
[(album_id, image_id) for image_id in image_ids],
232+
)
233+
return cursor.rowcount
234+
235+
135236
def db_update_album(
136237
album_id: str,
137238
album_name: str,
138239
description: str,
139240
is_locked: bool,
140-
password: str = None,
241+
password: Optional[str] = None,
141242
):
142-
conn = sqlite3.connect(DATABASE_PATH)
243+
conn = _connect()
143244
cursor = conn.cursor()
144245
try:
145246
if password is not None:
@@ -150,7 +251,8 @@ def db_update_album(
150251
cursor.execute(
151252
"""
152253
UPDATE albums
153-
SET album_name = ?, description = ?, is_locked = ?, password_hash = ?
254+
SET album_name = ?, description = ?, is_locked = ?, password_hash = ?,
255+
updated_at = CURRENT_TIMESTAMP
154256
WHERE album_id = ?
155257
""",
156258
(album_name, description, int(is_locked), password_hash, album_id),
@@ -160,7 +262,8 @@ def db_update_album(
160262
cursor.execute(
161263
"""
162264
UPDATE albums
163-
SET album_name = ?, description = ?, is_locked = ?
265+
SET album_name = ?, description = ?, is_locked = ?,
266+
updated_at = CURRENT_TIMESTAMP
164267
WHERE album_id = ?
165268
""",
166269
(album_name, description, int(is_locked), album_id),
@@ -178,7 +281,7 @@ def db_delete_album(album_id: str):
178281

179282
def db_get_album_cover_path(album_id: str) -> str | None:
180283
"""Path of the album's cover: its first image, by insertion order."""
181-
conn = sqlite3.connect(DATABASE_PATH)
284+
conn = _connect()
182285
cursor = conn.cursor()
183286
try:
184287
cursor.execute(
@@ -199,7 +302,7 @@ def db_get_album_cover_path(album_id: str) -> str | None:
199302

200303

201304
def db_get_album_images(album_id: str):
202-
conn = sqlite3.connect(DATABASE_PATH)
305+
conn = _connect()
203306
cursor = conn.cursor()
204307
try:
205308
cursor.execute(
@@ -247,6 +350,11 @@ def db_add_images_to_album(album_id: str, image_ids: list[str]):
247350
"INSERT OR IGNORE INTO album_images (album_id, image_id) VALUES (?, ?)",
248351
[(album_id, img_id) for img_id in valid_images],
249352
)
353+
# Every id may already be in the album, in which case OR IGNORE writes
354+
# nothing and the album has not actually changed. Read before touching:
355+
# the touch overwrites rowcount.
356+
if cursor.rowcount:
357+
_touch_album(cursor, album_id)
250358
conn.commit()
251359

252360

@@ -265,25 +373,29 @@ def db_remove_image_from_album(album_id: str, image_id: str):
265373
"DELETE FROM album_images WHERE album_id = ? AND image_id = ?",
266374
(album_id, image_id),
267375
)
376+
_touch_album(cursor, album_id)
268377
else:
269378
raise ValueError("Image not found in the specified album")
270379

271380

272381
def db_remove_images_from_album(album_id: str, image_ids: list[str]):
273-
conn = sqlite3.connect(DATABASE_PATH)
382+
conn = _connect()
274383
cursor = conn.cursor()
275384
try:
276385
cursor.executemany(
277386
"DELETE FROM album_images WHERE album_id = ? AND image_id = ?",
278387
[(album_id, img_id) for img_id in image_ids],
279388
)
389+
# Same as the insert: ids that were not in the album delete nothing.
390+
if cursor.rowcount:
391+
_touch_album(cursor, album_id)
280392
conn.commit()
281393
finally:
282394
conn.close()
283395

284396

285397
def verify_album_password(album_id: str, password: str) -> bool:
286-
conn = sqlite3.connect(DATABASE_PATH)
398+
conn = _connect()
287399
cursor = conn.cursor()
288400
try:
289401
cursor.execute(

0 commit comments

Comments
 (0)