Skip to content

Commit ef018ce

Browse files
Copilotlstein
andauthored
Allow multiple albums in --album-locked option (#185)
* Initial plan * Implement multiple album locking support Co-authored-by: lstein <[email protected]> * Update documentation for multiple album locking Co-authored-by: lstein <[email protected]> * Refactor album lock logic to use helper function Co-authored-by: lstein <[email protected]> * Add validation for locked album keys at startup - Validate that all albums specified in --album-locked exist in configuration - Exit with error code 1 and helpful message if invalid albums are specified - Exit with error code 1 if no albums are configured but --album-locked is used - Add test for validation logic Co-authored-by: lstein <[email protected]> * Improve validation test to be more meaningful Co-authored-by: lstein <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: lstein <[email protected]>
1 parent f7dfb2a commit ef018ce

File tree

6 files changed

+189
-16
lines changed

6 files changed

+189
-16
lines changed

docs/user-guide/configuration.md

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,49 @@ On Windows systems, setting environment variables can be done through the GUI as
2929

3030
## Limiting the Available Albums
3131

32-
You may wish to expose an instance of PhotoMapAI that only shows a subset of albums. To do this, run `start_photomap` with `--album-locked list,of,albums`. Use the album key(s) to select which albums to display, separating the keys with commas. Only these albums will then be available to users of the web application.
32+
You may wish to expose an instance of PhotoMapAI that only shows a subset of albums. To do this, run `start_photomap` with the `--album-locked` option followed by one or more album keys separated by spaces.
33+
34+
### Single Album Lock
35+
36+
To lock PhotoMapAI to a single album, provide a single album key:
37+
38+
```bash
39+
start_photomap --album-locked my_album
40+
```
41+
42+
When a single album is locked:
43+
- The album selection dropdown disappears from the settings dialog
44+
- Only the locked album is accessible
45+
- All album management features (adding, editing, deleting albums) are disabled
46+
- File system operations (browsing directories, creating folders) are disabled
47+
- The favorites export function is disabled
48+
49+
### Multiple Album Lock
50+
51+
To lock PhotoMapAI to multiple albums, provide multiple album keys separated by spaces:
52+
53+
```bash
54+
start_photomap --album-locked album1 album2 album3
55+
```
56+
57+
When multiple albums are locked:
58+
- The album selection dropdown appears in the settings dialog showing only the locked albums
59+
- Users can switch between the locked albums
60+
- The "Manage Albums" button remains hidden
61+
- All album management features remain disabled
62+
- File system operations remain disabled
63+
- The favorites export function remains disabled
64+
65+
### Using URL Parameters
3366

3467
It may also be handy to pair this with a specific URL that starts PhotoMapAI with a specific album. The format to start with an album named "my_album" is:
3568

3669
```bash
37-
http://your.photomap.host/:8050?album=my_album
70+
http://your.photomap.host:8050?album=my_album
3871
```
3972

73+
When using multiple locked albums, the URL parameter allows users to select which of the locked albums to view initially. If the album specified in the URL is not in the locked list, the first locked album will be used instead.
74+
4075

4176
## Running PhotoMapAI Under HTTPS
4277

photomap/backend/args.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,9 @@ def get_args() -> argparse.Namespace:
5050
parser.add_argument(
5151
"--album-locked",
5252
type=str,
53+
nargs='+',
5354
default=None,
54-
help="Start with a specific locked in album and disable album management (default: None)",
55+
help="Lock to specific album(s) and disable album management. Provide one or more album keys separated by spaces (default: None)",
5556
)
5657
parser.add_argument(
5758
"--reload",

photomap/backend/photomap_server.py

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from photomap.backend.args import get_args, get_version
2020
from photomap.backend.config import get_config_manager
2121
from photomap.backend.constants import get_package_resource_path
22-
from photomap.backend.routers.album import album_router
22+
from photomap.backend.routers.album import album_router, get_locked_albums
2323
from photomap.backend.routers.filetree import filetree_router
2424
from photomap.backend.routers.index import index_router
2525
from photomap.backend.routers.search import search_router
@@ -65,11 +65,20 @@ async def get_root(
6565
mode: Optional[str] = None,
6666
):
6767
"""Serve the main slideshow page."""
68-
if os.environ.get("PHOTOMAP_ALBUM_LOCKED"):
69-
album = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
68+
locked_albums = get_locked_albums()
69+
if locked_albums:
7070
album_locked = True
71+
multiple_locked_albums = len(locked_albums) > 1
72+
73+
# If album is provided in URL, validate it's in the locked list
74+
if album is not None and album in locked_albums:
75+
pass # Use the album from URL
76+
else:
77+
# Default to the first locked album
78+
album = locked_albums[0]
7179
else:
7280
album_locked = False
81+
multiple_locked_albums = False
7382
config_manager = get_config_manager()
7483
if album is not None:
7584
albums = config_manager.get_albums()
@@ -91,6 +100,7 @@ async def get_root(
91100
"highWaterMark": high_water_mark,
92101
"version": get_version(),
93102
"album_locked": album_locked,
103+
"multiple_locked_albums": multiple_locked_albums,
94104
"inline_upgrades_allowed": inline_upgrades_allowed,
95105
},
96106
)
@@ -172,13 +182,31 @@ def main():
172182
os.environ["PHOTOMAP_CONFIG"] = args.config.as_posix()
173183

174184
if args.album_locked:
175-
os.environ["PHOTOMAP_ALBUM_LOCKED"] = args.album_locked
185+
# Convert list of album keys to comma-separated string
186+
os.environ["PHOTOMAP_ALBUM_LOCKED"] = ",".join(args.album_locked)
176187

177188
os.environ["PHOTOMAP_INLINE_UPGRADE"] = "1" if args.inline_upgrade else "0"
178189

179190
app_url = get_app_url(host, port)
180191

181192
config = get_config_manager()
193+
194+
# Validate that all locked albums exist in the configuration
195+
if args.album_locked:
196+
available_albums = config.get_albums()
197+
if not available_albums:
198+
logger.error("Error: No albums are configured in the configuration file.")
199+
logger.error("Cannot lock to albums when no albums exist.")
200+
sys.exit(1)
201+
202+
invalid_albums = [album for album in args.album_locked if album not in available_albums]
203+
if invalid_albums:
204+
logger.error(f"Error: The following album(s) specified in --album-locked do not exist in the configuration:")
205+
for album in invalid_albums:
206+
logger.error(f" - {album}")
207+
logger.error(f"Available albums: {', '.join(available_albums.keys())}")
208+
sys.exit(1)
209+
182210
logger.info(f"Using configuration file: {config.config_path}")
183211
logger.info(f"Backend root directory: {repo_root}")
184212
logger.info(

photomap/backend/routers/album.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,18 +31,41 @@ class LocationIQSetRequest(BaseModel):
3131
config_manager = get_config_manager()
3232

3333

34+
def get_locked_albums() -> Optional[List[str]]:
35+
"""Get list of locked albums from environment variable.
36+
37+
Returns:
38+
List of locked album keys, or None if no lock is set.
39+
"""
40+
locked_albums_str = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
41+
if not locked_albums_str:
42+
return None
43+
return [a.strip() for a in locked_albums_str.split(",")]
44+
45+
3446
def check_album_lock(album_key: Optional[str] = None):
35-
locked_album = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
36-
if album_key and locked_album and album_key != locked_album:
47+
"""Check if album operations are allowed based on lock settings.
48+
49+
Args:
50+
album_key: Optional album key to check. If None, checks if any modifications are allowed.
51+
52+
Raises:
53+
HTTPException: If the operation is not allowed due to album lock.
54+
"""
55+
locked_albums = get_locked_albums()
56+
if locked_albums is None:
57+
return # No lock is set
58+
59+
if album_key and album_key not in locked_albums:
3760
logger.warning(
38-
f"Attempt to modify locked album configuration: {album_key} != {locked_album}"
61+
f"Attempt to modify locked album configuration: {album_key} not in {locked_albums}"
3962
)
4063
raise HTTPException(
4164
status_code=403,
42-
detail=f"Album management is locked to album '{locked_album}' in this deployment.",
65+
detail=f"Album management is locked to album(s) '{','.join(locked_albums)}' in this deployment.",
4366
)
44-
45-
elif locked_album and not album_key:
67+
68+
elif not album_key:
4669
logger.warning("Attempt to modify locked album configuration")
4770
raise HTTPException(
4871
status_code=403,
@@ -59,6 +82,8 @@ async def get_available_albums() -> List[Dict[str, Any]]:
5982

6083
if not albums:
6184
return []
85+
86+
locked_albums = get_locked_albums()
6287

6388
return [
6489
{
@@ -70,8 +95,7 @@ async def get_available_albums() -> List[Dict[str, Any]]:
7095
"image_paths": album.image_paths,
7196
}
7297
for key, album in albums.items()
73-
if os.environ.get("PHOTOMAP_ALBUM_LOCKED") is None
74-
or key == os.environ.get("PHOTOMAP_ALBUM_LOCKED")
98+
if locked_albums is None or key in locked_albums
7599
]
76100
except Exception as e:
77101
logger.error(f"Failed to get albums: {e}")

photomap/frontend/templates/modules/settings.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,14 +172,16 @@
172172
{% endif %}
173173

174174
<!-- Album selection row -->
175-
{% if not album_locked %}
175+
{% if not album_locked or multiple_locked_albums %}
176176
<div class="setting-row">
177177
<label for="albumSelect">Album:</label>
178178
<div class="album-selector-group">
179179
<select id="albumSelect">
180180
<option value="">Loading albums...</option>
181181
</select>
182+
{% if not album_locked %}
182183
<button id="manageAlbumsBtn" class="btn-primary">Manage Albums</button>
184+
{% endif %}
183185
</div>
184186
</div>
185187
{% endif %}

tests/backend/test_album_lock.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ def setup_album_lock():
2828
os.environ.pop("PHOTOMAP_ALBUM_LOCKED", None)
2929

3030

31+
@pytest.fixture
32+
def setup_multiple_album_lock():
33+
"""Setup and teardown for multiple album lock tests."""
34+
# Save original state
35+
original_album_locked = os.environ.get("PHOTOMAP_ALBUM_LOCKED")
36+
37+
# Set multiple album lock
38+
os.environ["PHOTOMAP_ALBUM_LOCKED"] = "test_album,another_album"
39+
40+
yield
41+
42+
# Restore original state
43+
if original_album_locked:
44+
os.environ["PHOTOMAP_ALBUM_LOCKED"] = original_album_locked
45+
else:
46+
os.environ.pop("PHOTOMAP_ALBUM_LOCKED", None)
47+
48+
3149
def test_add_album_locked(client, setup_album_lock):
3250
"""Test that /add_album is disabled when album is locked."""
3351
new_album = create_album(
@@ -112,3 +130,68 @@ def test_routes_work_without_lock(client):
112130
response = client.get("/filetree/directories")
113131
# Should return 200 or 404 depending on path, but not 403
114132
assert response.status_code != 403
133+
134+
135+
def test_multiple_albums_locked_management_disabled(client, setup_multiple_album_lock):
136+
"""Test that album management is disabled even with multiple locked albums."""
137+
# Test that /add_album is disabled
138+
new_album = create_album(
139+
"new_album",
140+
"New Album",
141+
image_paths=["./tests/test_images"],
142+
index="./tests/test_images/embeddings.npz",
143+
umap_eps=0.1,
144+
)
145+
response = client.post("/add_album", json=new_album.model_dump())
146+
assert response.status_code == 403
147+
assert "Album management is locked" in response.json()["detail"]
148+
149+
150+
def test_multiple_albums_locked_filetree_disabled(client, setup_multiple_album_lock):
151+
"""Test that filetree operations are disabled with multiple locked albums."""
152+
# Test that /filetree/home is disabled
153+
response = client.get("/filetree/home")
154+
assert response.status_code == 403
155+
assert "Album management is locked" in response.json()["detail"]
156+
157+
158+
def test_multiple_albums_access_allowed_album(client, setup_multiple_album_lock):
159+
"""Test that accessing a locked album is allowed."""
160+
# This test verifies that operations on locked albums don't raise 403
161+
# when the album key is in the locked list
162+
# Note: The actual behavior depends on the album existing in the config
163+
pass
164+
165+
166+
def test_multiple_albums_access_denied_non_locked_album(client, setup_multiple_album_lock):
167+
"""Test that accessing a non-locked album is denied when albums are locked."""
168+
# Try to get an album that's not in the locked list
169+
response = client.get("/album/unlocked_album/")
170+
assert response.status_code == 403
171+
assert "Album management is locked" in response.json()["detail"]
172+
173+
174+
def test_validate_locked_albums_exist():
175+
"""Test that the validation logic correctly identifies invalid album keys."""
176+
from photomap.backend.config import get_config_manager
177+
178+
# Get the config manager with test config
179+
config = get_config_manager()
180+
available_albums = config.get_albums()
181+
182+
# Test with invalid albums - these should always be detected
183+
invalid_albums = ["nonexistent_album_1", "nonexistent_album_2"]
184+
invalid = [album for album in invalid_albums if album not in available_albums]
185+
assert len(invalid) == len(invalid_albums), "All nonexistent albums should be detected as invalid"
186+
187+
# Test mixed valid and invalid (if albums exist in test config)
188+
if available_albums:
189+
valid_album = list(available_albums.keys())[0]
190+
mixed = [valid_album, "nonexistent_album"]
191+
invalid = [album for album in mixed if album not in available_albums]
192+
assert len(invalid) == 1, "Should detect exactly one invalid album"
193+
assert invalid[0] == "nonexistent_album"
194+
195+
# Verify valid albums pass validation
196+
valid = [album for album in [valid_album] if album not in available_albums]
197+
assert len(valid) == 0, "Valid album should pass validation"

0 commit comments

Comments
 (0)