|
18 | 18 | AssetValidationError, |
19 | 19 | UploadError, |
20 | 20 | ) |
21 | | -from app.assets.helpers import validate_blake3_hash |
| 21 | +from app.assets.helpers import normalize_tags, validate_blake3_hash |
22 | 22 | from app.assets.api.upload import ( |
23 | 23 | delete_temp_file_if_exists, |
24 | 24 | parse_multipart_upload, |
@@ -117,6 +117,87 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp |
117 | 117 | return _build_error_response(400, code, "Validation failed.", {"errors": errors}) |
118 | 118 |
|
119 | 119 |
|
| 120 | +class InvalidTagFilterError(Exception): |
| 121 | + """Invalid combination of tag-filter query parameters.""" |
| 122 | + |
| 123 | + def __init__(self, message: str, details: dict): |
| 124 | + super().__init__(message) |
| 125 | + self.details = details |
| 126 | + |
| 127 | + |
| 128 | +# Caps the per-tag EXISTS fan-out; deliberately covers the legacy spellings too. |
| 129 | +MAX_TAG_FILTER_TAGS = 100 |
| 130 | + |
| 131 | + |
| 132 | +def _resolve_tag_filters( |
| 133 | + q: schemas_in.ListAssetsQuery | schemas_in.TagsRefineQuery, |
| 134 | +) -> tuple[list[str], list[str], list[str]]: |
| 135 | + """Resolve legacy (include/exclude) and new (all/any/none) tag-filter |
| 136 | + spellings into effective (all, any, none) lists. |
| 137 | +
|
| 138 | + Combination validation applies only when the request uses at least one |
| 139 | + new-name parameter (non-empty after normalisation); requests using only |
| 140 | + the legacy names keep their historical behaviour, including degenerate |
| 141 | + combinations like include_tags=a&exclude_tags=a. |
| 142 | + """ |
| 143 | + # model_dump, not attribute access: deprecated fields warn on every attribute read. |
| 144 | + legacy = q.model_dump(include={"include_tags", "exclude_tags"}) |
| 145 | + include_tags = normalize_tags(legacy["include_tags"]) |
| 146 | + exclude_tags = normalize_tags(legacy["exclude_tags"]) |
| 147 | + tags_all = normalize_tags(q.tags_all) |
| 148 | + tags_any = normalize_tags(q.tags_any) |
| 149 | + tags_none = normalize_tags(q.tags_none) |
| 150 | + |
| 151 | + for param_name, values in ( |
| 152 | + ("include_tags", include_tags), |
| 153 | + ("exclude_tags", exclude_tags), |
| 154 | + ("tags_all", tags_all), |
| 155 | + ("tags_any", tags_any), |
| 156 | + ("tags_none", tags_none), |
| 157 | + ): |
| 158 | + if len(values) > MAX_TAG_FILTER_TAGS: |
| 159 | + raise InvalidTagFilterError( |
| 160 | + f"'{param_name}' lists {len(values)} tags; the maximum is " |
| 161 | + f"{MAX_TAG_FILTER_TAGS}.", |
| 162 | + { |
| 163 | + "parameter": param_name, |
| 164 | + "count": len(values), |
| 165 | + "max": MAX_TAG_FILTER_TAGS, |
| 166 | + }, |
| 167 | + ) |
| 168 | + |
| 169 | + if not (tags_all or tags_any or tags_none): |
| 170 | + return include_tags, [], exclude_tags |
| 171 | + |
| 172 | + if include_tags and tags_all: |
| 173 | + raise InvalidTagFilterError( |
| 174 | + "Cannot combine 'include_tags' with 'tags_all'; use 'tags_all'.", |
| 175 | + {"parameters": ["include_tags", "tags_all"]}, |
| 176 | + ) |
| 177 | + if exclude_tags and tags_none: |
| 178 | + raise InvalidTagFilterError( |
| 179 | + "Cannot combine 'exclude_tags' with 'tags_none'; use 'tags_none'.", |
| 180 | + {"parameters": ["exclude_tags", "tags_none"]}, |
| 181 | + ) |
| 182 | + |
| 183 | + all_param, all_list = ( |
| 184 | + ("tags_all", tags_all) if tags_all else ("include_tags", include_tags) |
| 185 | + ) |
| 186 | + none_param, none_list = ( |
| 187 | + ("tags_none", tags_none) if tags_none else ("exclude_tags", exclude_tags) |
| 188 | + ) |
| 189 | + |
| 190 | + conflicting = sorted(set(all_list) & set(none_list)) |
| 191 | + if conflicting: |
| 192 | + raise InvalidTagFilterError( |
| 193 | + f"Query can never match: {', '.join(repr(t) for t in conflicting)} " |
| 194 | + f"required by '{all_param}' but rejected by '{none_param}'.", |
| 195 | + {"conflicting_tags": conflicting, "parameters": [all_param, none_param]}, |
| 196 | + ) |
| 197 | + |
| 198 | + return all_list, tags_any, none_list |
| 199 | + |
| 200 | + |
120 | 201 | def _validate_sort_field(requested: str | None) -> str: |
121 | 202 | if not requested: |
122 | 203 | return "created_at" |
@@ -217,15 +298,21 @@ async def list_assets_route(request: web.Request) -> web.Response: |
217 | 298 | except ValidationError as ve: |
218 | 299 | return _build_validation_error_response("INVALID_QUERY", ve) |
219 | 300 |
|
| 301 | + try: |
| 302 | + tags_all, tags_any, tags_none = _resolve_tag_filters(q) |
| 303 | + except InvalidTagFilterError as e: |
| 304 | + return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) |
| 305 | + |
220 | 306 | sort = _validate_sort_field(q.sort) |
221 | 307 | order_candidate = (q.order or "desc").lower() |
222 | 308 | order = order_candidate if order_candidate in {"asc", "desc"} else "desc" |
223 | 309 |
|
224 | 310 | try: |
225 | 311 | result = list_assets_page( |
226 | 312 | owner_id=USER_MANAGER.get_request_user_id(request), |
227 | | - include_tags=q.include_tags, |
228 | | - exclude_tags=q.exclude_tags, |
| 313 | + include_tags=tags_all, |
| 314 | + exclude_tags=tags_none, |
| 315 | + any_tags=tags_any, |
229 | 316 | name_contains=q.name_contains, |
230 | 317 | metadata_filter=q.metadata_filter, |
231 | 318 | limit=q.limit, |
@@ -715,10 +802,16 @@ async def get_tags_refine(request: web.Request) -> web.Response: |
715 | 802 | except ValidationError as ve: |
716 | 803 | return _build_validation_error_response("INVALID_QUERY", ve) |
717 | 804 |
|
| 805 | + try: |
| 806 | + tags_all, tags_any, tags_none = _resolve_tag_filters(q) |
| 807 | + except InvalidTagFilterError as e: |
| 808 | + return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) |
| 809 | + |
718 | 810 | tag_counts = list_tag_histogram( |
719 | 811 | owner_id=USER_MANAGER.get_request_user_id(request), |
720 | | - include_tags=q.include_tags, |
721 | | - exclude_tags=q.exclude_tags, |
| 812 | + include_tags=tags_all, |
| 813 | + exclude_tags=tags_none, |
| 814 | + any_tags=tags_any, |
722 | 815 | name_contains=q.name_contains, |
723 | 816 | metadata_filter=q.metadata_filter, |
724 | 817 | limit=q.limit, |
|
0 commit comments