diff --git a/.vscode/settings.json b/.vscode/settings.json index 980596b..58382f3 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,5 +11,6 @@ "prettier.tabWidth": 2, "editor.tabSize": 2 }, - "djlint.ignore": "H006" + "djlint.ignore": "H006", + "python-envs.pythonProjects": [] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9c83c80..cc325cd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,9 +29,8 @@ ENV MEDIA_ROOT=/app/data/media WORKDIR /app -# Install cron, gettext and other system dependencies +# Install system dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ - cron \ gettext \ && rm -rf /var/lib/apt/lists/* @@ -57,14 +56,6 @@ RUN mkdir -p /app/data/media /app/data/backups COPY scripts/daily_backup.sh /app/daily_backup.sh RUN chmod +x /app/daily_backup.sh -# Setup cron job for daily backups at 1 AM -# Include PATH to ensure uv and other binaries are available -# Output goes to both log file and stdout (for docker logs visibility) -RUN echo "PATH=/usr/local/bin:/usr/bin:/bin" > /etc/cron.d/datakult-backup && \ - echo "0 1 * * * /app/daily_backup.sh 2>&1 | tee -a /var/log/cron.log" >> /etc/cron.d/datakult-backup && \ - chmod 0644 /etc/cron.d/datakult-backup && \ - touch /var/log/cron.log - # Copy entrypoint script COPY scripts/entrypoint.sh /app/entrypoint.sh RUN chmod +x /app/entrypoint.sh diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index eeda68d..69a375f 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -22,30 +22,6 @@ else: print(f'Superuser {username} already exists.') " -# Reload crontab to ensure it's active at runtime -echo "Reloading crontab..." -if [ -f /etc/cron.d/datakult-backup ]; then - crontab /etc/cron.d/datakult-backup - echo "✓ Crontab loaded" -else - echo "✗ Warning: Crontab file not found" -fi - -# Start cron in the background for daily backups -echo "Starting cron for daily backups..." -if cron; then - echo "✓ Cron service started" - # Verify cron is running - sleep 1 - if pgrep cron > /dev/null; then - echo "✓ Cron process confirmed running" - else - echo "✗ Warning: Cron process not found" - fi -else - echo "✗ Warning: Failed to start cron service" -fi - # Start the server echo "Starting server..." exec "$@" diff --git a/src/core/filters.py b/src/core/filters.py new file mode 100644 index 0000000..1753b13 --- /dev/null +++ b/src/core/filters.py @@ -0,0 +1,152 @@ +"""Media filtering and sorting utilities.""" + +import contextlib + +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.utils.translation import gettext as _ + +from .models import Agent, Media + + +def resolve_sorting(request): + """Return validated sorting info: selected field and normalized sort string (with sign).""" + default_field = "review_date" + sort = request.GET.get("sort") or request.GET.get("order_by") or f"-{default_field}" + + raw_field = sort.lstrip("-") + valid_fields = {"created_at", "review_date", "score"} + sort_field = raw_field if raw_field in valid_fields else default_field + + is_desc = sort.startswith("-") + normalized_sort = f"-{sort_field}" if is_desc else sort_field + return sort_field, normalized_sort + + +def extract_filters(request): + """Extract filter parameters from request and return filters dict.""" + filters = { + "contributor": request.GET.get("contributor", ""), + "type": request.GET.getlist("type"), + "status": request.GET.getlist("status"), + "score": request.GET.getlist("score"), + "review_from": request.GET.get("review_from", ""), + "review_to": request.GET.get("review_to", ""), + "has_review": request.GET.get("has_review", ""), + "has_cover": request.GET.get("has_cover", ""), + } + filters["has_any"] = any( + [ + filters["type"], + filters["status"], + filters["score"], + filters["review_from"], + filters["review_to"], + filters["has_review"], + filters["has_cover"], + ] + ) + + # Add display names for active filters (as list of tuples: (value, label)) + if filters["type"]: + type_choices_dict = dict(Media.media_type.field.choices) + filters["type_display"] = [(t, type_choices_dict.get(t, t)) for t in filters["type"]] + if filters["status"]: + status_choices_dict = dict(Media.status.field.choices) + filters["status_display"] = [(s, status_choices_dict.get(s, s)) for s in filters["status"]] + if filters["score"]: + score_choices_dict = dict(Media.score.field.choices) + filters["score_display"] = [] + for s in filters["score"]: + if s == "none": + filters["score_display"].append(("none", _("Not rated"))) + else: + try: + filters["score_display"].append((s, score_choices_dict.get(int(s), s))) + except ValueError: + # Skip malformed score values from URL + continue + + return filters + + +def get_field_choices(): + """Return choices for filter fields from the Media model.""" + return { + "media_type_choices": Media.media_type.field.choices, + "status_choices": Media.status.field.choices, + "score_choices": Media.score.field.choices, + } + + +def apply_contributor_filter(queryset, contributor_id): + """Apply contributor filter to queryset and return (queryset, contributor).""" + + contributor = None + if contributor_id: + contributor = Agent.objects.filter(pk=contributor_id).first() + if contributor: + queryset = queryset.filter(contributors=contributor) + return queryset, contributor + + +def apply_type_filter(queryset, media_types): + """Apply OR filter for media types.""" + if not media_types: + return queryset + return queryset.filter(media_type__in=media_types) + + +def apply_status_filter(queryset, statuses): + """Apply OR filter for statuses.""" + if not statuses: + return queryset + return queryset.filter(status__in=statuses) + + +def apply_score_filter(queryset, scores): + """Apply OR filter for scores (including 'none' for null scores).""" + if not scores: + return queryset + score_q = Q() + for score in scores: + if score == "none": + score_q |= Q(score__isnull=True) + else: + try: + score_q |= Q(score=int(score)) + except ValueError: + # Skip malformed score values from URL + continue + return queryset.filter(score_q) + + +def apply_date_and_content_filters(queryset, filters): + """Apply review date, review content, and cover filters.""" + if filters["review_from"]: + # Skip malformed date values from URL + with contextlib.suppress(ValueError, TypeError, ValidationError): + queryset = queryset.filter(review_date__gte=filters["review_from"]) + if filters["review_to"]: + # Skip malformed date values from URL + with contextlib.suppress(ValueError, TypeError, ValidationError): + queryset = queryset.filter(review_date__lte=filters["review_to"]) + if filters["has_review"] == "empty": + queryset = queryset.filter(Q(review__isnull=True) | Q(review="")) + elif filters["has_review"] == "filled": + queryset = queryset.exclude(Q(review__isnull=True) | Q(review="")) + if filters["has_cover"] == "empty": + queryset = queryset.filter(Q(cover__isnull=True) | Q(cover="")) + elif filters["has_cover"] == "filled": + queryset = queryset.exclude(Q(cover__isnull=True) | Q(cover="")) + return queryset + + +def apply_filters(queryset, filters): + """Apply filters to a queryset and return (queryset, contributor).""" + queryset, contributor = apply_contributor_filter(queryset, filters["contributor"]) + queryset = apply_type_filter(queryset, filters["type"]) + queryset = apply_status_filter(queryset, filters["status"]) + queryset = apply_score_filter(queryset, filters["score"]) + queryset = apply_date_and_content_filters(queryset, filters) + return queryset, contributor diff --git a/src/core/fixtures/sample_data.json b/src/core/fixtures/sample_data.json index f82c83d..28fa0ec 100644 --- a/src/core/fixtures/sample_data.json +++ b/src/core/fixtures/sample_data.json @@ -106,7 +106,7 @@ "external_uri": "https://www.imdb.com/title/tt1160419/", "status": "COMPLETED", "pub_year": 2021, - "review": "Une adaptation magistrale du roman de Frank Herbert. La photographie et la bande sonore sont exceptionnelles.", + "review": "## A Masterpiece of Adaptation\n\nA **magnificent** adaptation of Frank Herbert's novel. The cinematography is *absolutely stunning*, and Hans Zimmer's soundtrack creates an **immersive experience** that perfectly captures the vastness and mystery of Arrakis. Denis Villeneuve has crafted a visually striking film that honors the source material while making it accessible to modern audiences. Every frame feels meticulously composed.", "score": 9, "review_date": "2021-10", "contributors": [2], @@ -117,12 +117,12 @@ "model": "core.media", "pk": 2, "fields": { - "title": "Dune (roman)", + "title": "Dune (novel)", "media_type": "BOOK", "external_uri": "https://www.goodreads.com/book/show/44767458-dune", "status": "COMPLETED", "pub_year": 1965, - "review": "Un chef-d'œuvre de la science-fiction. L'univers créé par Herbert est d'une richesse incroyable.", + "review": "A **masterpiece** of science fiction. The universe created by Herbert is *incredibly rich* and detailed, with complex political intrigue, ecology, religion, and philosophy woven throughout. The depth of world-building is unmatched in the genre.", "score": 10, "review_date": "2020", "contributors": [3], @@ -138,7 +138,7 @@ "external_uri": "https://www.imdb.com/title/tt0816692/", "status": "COMPLETED", "pub_year": 2014, - "review": "Un voyage émotionnel à travers l'espace et le temps.", + "review": "An emotional journey through space and time. Nolan delivers a **powerful meditation** on love, sacrifice, and humanity's survival instinct. The scientific concepts are fascinating, and the emotional core featuring the father-daughter relationship is *genuinely moving*. The docking sequence remains one of cinema's most **thrilling moments**.", "score": 8, "review_date": "2014-11-15", "contributors": [1], @@ -149,12 +149,12 @@ "model": "core.media", "pk": 4, "fields": { - "title": "Le Voyage de Chihiro", + "title": "Spirited Away", "media_type": "FILM", "external_uri": "https://www.imdb.com/title/tt0245429/", "status": "COMPLETED", "pub_year": 2001, - "review": "Un conte féerique magnifique.", + "review": "A *magnificent* fairy tale masterpiece.", "score": 10, "review_date": "2015", "contributors": [4], @@ -185,7 +185,7 @@ "external_uri": "https://www.discogs.com/master/21491-Radiohead-OK-Computer", "status": "COMPLETED", "pub_year": 1997, - "review": "Un album révolutionnaire qui a redéfini le rock alternatif.", + "review": "### Revolutionary Sound\n\nA **revolutionary album** that redefined alternative rock. Radiohead created a sonic landscape that perfectly captured the alienation and anxiety of the late 90s. From the paranoid energy of *Paranoid Android* to the haunting beauty of *No Surprises*, every track is **essential**. This album influenced countless artists and remains timeless.", "score": 9, "review_date": "2018", "contributors": [6], @@ -201,7 +201,7 @@ "external_uri": "https://www.imdb.com/title/tt0903747/", "status": "COMPLETED", "pub_year": 2008, - "review": "Une série parfaitement maîtrisée du début à la fin.", + "review": "A **perfectly crafted** series from beginning to end. The character development of Walter White is one of television's greatest achievements. *Brilliant* writing, directing, and performances throughout.", "score": 10, "review_date": "2020", "contributors": [], @@ -217,7 +217,7 @@ "external_uri": "https://www.imdb.com/title/tt15239678/", "status": "COMPLETED", "pub_year": 2024, - "review": "Une suite encore plus épique que le premier volet.", + "review": "A sequel that's even more **epic** than the first installment. The action sequences are *breathtaking*, and the character development reaches new heights. Villeneuve has created something truly special here.", "score": 9, "review_date": "2024-03", "contributors": [2] @@ -232,7 +232,7 @@ "external_uri": "https://www.igdb.com/games/shadow-of-the-colossus", "status": "COMPLETED", "pub_year": 2005, - "review": "Une expérience unique, mélancolique et poétique.", + "review": "A unique experience, *melancholic* and poetic. Each colossus battle feels like a monumental achievement, yet the game makes you question your actions. The minimalist storytelling and **gorgeous** landscapes create an unforgettable journey. A true work of art that transcends the medium.", "score": 9, "review_date": "2018", "contributors": [7], @@ -243,12 +243,12 @@ "model": "core.media", "pk": 10, "fields": { - "title": "Fondation", + "title": "Foundation", "media_type": "BOOK", "external_uri": "https://www.goodreads.com/book/show/29579.Foundation", "status": "COMPLETED", "pub_year": 1951, - "review": "Le pilier de la science-fiction moderne. Une saga épique sur la chute et la renaissance des civilisations.", + "review": "## The Pillar of Modern Sci-Fi\n\nThe **pillar** of modern science fiction. An epic saga about the fall and rebirth of civilizations, exploring psychohistory and the patterns of human behavior across millennia. Asimov's vision of the future is both *intellectually stimulating* and deeply human. The concept of predicting societal movements through mathematics remains fascinating decades later.", "score": 9, "review_date": "2019", "contributors": [8], @@ -264,7 +264,7 @@ "external_uri": "https://www.imdb.com/title/tt0110912/", "status": "COMPLETED", "pub_year": 1994, - "review": "Un classique intemporel avec une structure narrative innovante.", + "review": "A **timeless classic** with an innovative narrative structure. Tarantino's *sharp dialogue* and non-linear storytelling revolutionized cinema in the 90s. Every scene is memorable, from the philosophical discussions to the intense action sequences.", "score": 9, "review_date": "2016", "contributors": [9], @@ -280,7 +280,7 @@ "external_uri": "https://www.imdb.com/title/tt6751668/", "status": "COMPLETED", "pub_year": 2019, - "review": "Un thriller social brillant qui mélange les genres avec brio.", + "review": "A **brilliant** social thriller that blends genres masterfully. Bong Joon-ho's commentary on class struggle is *devastatingly effective*, shifting from comedy to horror with seamless precision. The film's structure is **perfect**, with each act building tension until the explosive finale.", "score": 10, "review_date": "2020-02", "contributors": [10] @@ -295,7 +295,7 @@ "external_uri": "https://www.discogs.com/master/556257-Daft-Punk-Random-Access-Memories", "status": "COMPLETED", "pub_year": 2013, - "review": "Un album ambitieux qui célèbre la musique analogique.", + "review": "An **ambitious album** that celebrates analog music and live instrumentation. Daft Punk took a bold departure from their electronic roots, collaborating with legendary musicians. *Get Lucky* became an instant classic.", "score": 8, "review_date": "2013", "contributors": [11] @@ -321,12 +321,12 @@ "model": "core.media", "pk": 15, "fields": { - "title": "Sandman", + "title": "The Sandman", "media_type": "COMIC", "external_uri": "https://www.goodreads.com/series/40372-the-sandman", "status": "COMPLETED", "pub_year": 1989, - "review": "Une œuvre magistrale qui transcende le medium des comics.", + "review": "### Literary Mastery in Comics\n\nA **masterpiece** that transcends the comics medium. Neil Gaiman weaves mythology, literature, and philosophy into a *breathtaking* narrative about Dream and the Endless. The series moves effortlessly between horror, fantasy, and drama, creating something truly unique. Every arc is memorable, but *Season of Mists* and *The Kindly Ones* are particularly **outstanding**.", "score": 10, "review_date": "2021", "contributors": [13], @@ -342,7 +342,7 @@ "external_uri": "https://www.imdb.com/title/tt2278388/", "status": "COMPLETED", "pub_year": 2014, - "review": "Un bijou visuel avec une esthétique unique.", + "review": "A *visual gem* with a **unique aesthetic**. Wes Anderson's meticulous attention to detail and symmetrical compositions create a whimsical world.", "score": 8, "review_date": "2015", "contributors": [14] @@ -357,7 +357,7 @@ "external_uri": "https://www.igdb.com/games/dark-souls", "status": "COMPLETED", "pub_year": 2011, - "review": "Difficile mais incroyablement satisfaisant. Un level design exemplaire.", + "review": "**Challenging** but *incredibly satisfying*. The level design is exemplary, with interconnected areas that reward exploration and careful observation. Every death teaches you something, and overcoming obstacles feels genuinely rewarding. The cryptic lore and atmospheric world design are unmatched.", "score": 9, "review_date": "2017", "contributors": [12], @@ -368,12 +368,12 @@ "model": "core.media", "pk": 18, "fields": { - "title": "Princesse Mononoké", + "title": "Princess Mononoke", "media_type": "FILM", "external_uri": "https://www.imdb.com/title/tt0119698/", "status": "COMPLETED", "pub_year": 1997, - "review": "Une réflexion profonde sur la relation entre l'homme et la nature.", + "review": "A **profound reflection** on the relationship between humanity and nature. Miyazaki presents a *nuanced* story where there are no clear villains, only different perspectives on survival and progress. The animation is stunning.", "score": 9, "review_date": "2014", "contributors": [4], @@ -389,7 +389,7 @@ "external_uri": "https://www.imdb.com/title/tt2699128/", "status": "COMPLETED", "pub_year": 2014, - "review": "Une série profondément émouvante sur le deuil et l'espoir.", + "review": "### Deeply Moving Television\n\nA *deeply moving* series about grief and hope. The show explores loss and the human need for meaning with **raw emotional honesty**. Each character's journey is compelling, and the finale is one of the most *beautiful* conclusions in television history. The performances, particularly from Carrie Coon, are extraordinary. This series will stay with you long after watching.", "score": 10, "review_date": "2022", "contributors": [], @@ -405,7 +405,7 @@ "external_uri": "https://www.discogs.com/master/21501-Radiohead-Kid-A", "status": "COMPLETED", "pub_year": 2000, - "review": "Une expérimentation audacieuse qui a divisé les fans mais reste un chef-d'œuvre. Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + "review": "## A Bold Experiment\n\nA **bold experiment** that divided fans but remains a masterpiece. After the success of *OK Computer*, Radiohead took a radical turn toward electronic experimentation and ambient soundscapes. The album is *challenging* and rewards repeated listens, revealing new layers of complexity each time. Tracks like *Everything In Its Right Place* and *Idioteque* showcase the band's willingness to take risks and push boundaries. The haunting atmosphere and innovative production make this an **essential** album that influenced an entire generation of musicians.", "score": 9, "review_date": "2019", "contributors": [6], @@ -447,12 +447,12 @@ "model": "core.media", "pk": 23, "fields": { - "title": "Metal Gear Solid V", + "title": "Metal Gear Solid V: The Phantom Pain", "media_type": "GAME", "external_uri": "https://www.igdb.com/games/metal-gear-solid-v-the-phantom-pain", "status": "DNF", "pub_year": 2015, - "review": "Gameplay excellent mais l'histoire est décevante et incomplète.", + "review": "**Excellent gameplay** mechanics with unparalleled stealth options, but the story is *disappointing* and clearly incomplete. The open-world sandbox provides incredible freedom, yet the narrative fails to deliver the emotional depth expected from a Metal Gear game.", "score": 6, "review_date": "2016", "contributors": [5], diff --git a/src/core/queries.py b/src/core/queries.py new file mode 100644 index 0000000..53ff020 --- /dev/null +++ b/src/core/queries.py @@ -0,0 +1,58 @@ +"""Media queryset and pagination utilities.""" + +from django.core.paginator import Paginator +from django.db.models import Q + +from .filters import apply_filters, extract_filters, get_field_choices, resolve_sorting +from .models import Media + + +def build_search_queryset(query): + """Build a filtered queryset based on search query.""" + q_objects = Q(title__icontains=query) | Q(contributors__name__icontains=query) | Q(review__icontains=query) + + # Try to parse query as a year (integer) + try: + parsed_year = int(query) + q_objects |= Q(pub_year__exact=parsed_year) + except ValueError: + # Not a valid integer, skip year filtering + pass + + return Media.objects.filter(q_objects).distinct() + + +def build_media_context(request): + """ + Build and filter media queryset from request parameters. + + Returns a context_dict ready for rendering. + This consolidates the common logic used by index and load_more_media views. + """ + view_mode = request.GET.get("view_mode", "grid") + sort_field, sort = resolve_sorting(request) + filters = extract_filters(request) + search_query = request.GET.get("search", "").strip() + + # Build queryset based on whether it's a search or not + queryset = build_search_queryset(search_query) if search_query else Media.objects.all() + + # Apply filters and sorting + queryset, contributor = apply_filters(queryset, filters) + queryset = queryset.order_by(sort) + + # Pagination: 20 items per page + page_number = request.GET.get("page", 1) + paginator = Paginator(queryset, 20) + page_obj = paginator.get_page(page_number) + + return { + "media_list": page_obj.object_list, + "page_obj": page_obj, + "view_mode": view_mode, + "sort_field": sort_field, + "sort": sort, + "contributor": contributor, + "filters": filters, + **get_field_choices(), + } diff --git a/src/core/templatetags/media_tags.py b/src/core/templatetags/media_tags.py index 0cb9a04..e888d5f 100644 --- a/src/core/templatetags/media_tags.py +++ b/src/core/templatetags/media_tags.py @@ -7,7 +7,7 @@ MEDIA_TYPE_ICONS = { "BOOK": "book-open", - "GAME": "computer-desktop", + "GAME": "puzzle-piece", "MUSIC": "musical-note", "COMIC": "book-open", "FILM": "film", diff --git a/src/core/views.py b/src/core/views.py index f39a7cb..3e7e229 100644 --- a/src/core/views.py +++ b/src/core/views.py @@ -1,218 +1,25 @@ -import contextlib import tarfile import tempfile from pathlib import Path from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.exceptions import ValidationError from django.core.management import call_command from django.core.management.base import CommandError -from django.core.paginator import Paginator -from django.db.models import Q from django.http import FileResponse from django.shortcuts import get_object_or_404, redirect, render from django.utils.translation import gettext as _ from .forms import MediaForm from .models import Agent, Media +from .queries import build_media_context from .utils import create_backup, delete_orphan_agents_by_ids -def _resolve_sorting(request): - """Return validated sorting info: selected field and normalized sort string (with sign).""" - default_field = "review_date" - sort = request.GET.get("sort") or request.GET.get("order_by") or f"-{default_field}" - - raw_field = sort.lstrip("-") - valid_fields = {"created_at", "review_date", "score"} - sort_field = raw_field if raw_field in valid_fields else default_field - - is_desc = sort.startswith("-") - normalized_sort = f"-{sort_field}" if is_desc else sort_field - return sort_field, normalized_sort - - -def _extract_filters(request): - """Extract filter parameters from request and return filters dict.""" - filters = { - "contributor": request.GET.get("contributor", ""), - "type": request.GET.getlist("type"), - "status": request.GET.getlist("status"), - "score": request.GET.getlist("score"), - "review_from": request.GET.get("review_from", ""), - "review_to": request.GET.get("review_to", ""), - "has_review": request.GET.get("has_review", ""), - "has_cover": request.GET.get("has_cover", ""), - } - filters["has_any"] = any( - [ - filters["type"], - filters["status"], - filters["score"], - filters["review_from"], - filters["review_to"], - filters["has_review"], - filters["has_cover"], - ] - ) - - # Add display names for active filters (as list of tuples: (value, label)) - if filters["type"]: - type_choices_dict = dict(Media.media_type.field.choices) - filters["type_display"] = [(t, type_choices_dict.get(t, t)) for t in filters["type"]] - if filters["status"]: - status_choices_dict = dict(Media.status.field.choices) - filters["status_display"] = [(s, status_choices_dict.get(s, s)) for s in filters["status"]] - if filters["score"]: - score_choices_dict = dict(Media.score.field.choices) - filters["score_display"] = [] - for s in filters["score"]: - if s == "none": - filters["score_display"].append(("none", _("Not rated"))) - else: - try: - filters["score_display"].append((s, score_choices_dict.get(int(s), s))) - except ValueError: - # Skip malformed score values from URL - continue - - return filters - - -def _get_field_choices(): - """Return choices for filter fields from the Media model.""" - return { - "media_type_choices": Media.media_type.field.choices, - "status_choices": Media.status.field.choices, - "score_choices": Media.score.field.choices, - } - - -def _build_search_queryset(query): - """Build a filtered queryset based on search query.""" - return Media.objects.filter( - Q(title__icontains=query) - | Q(contributors__name__icontains=query) - | Q(pub_year__icontains=query) - | Q(review__icontains=query), - ).distinct() - - -def _apply_contributor_filter(queryset, contributor_id): - """Apply contributor filter to queryset and return (queryset, contributor).""" - contributor = None - if contributor_id: - contributor = Agent.objects.filter(pk=contributor_id).first() - if contributor: - queryset = queryset.filter(contributors=contributor) - return queryset, contributor - - -def _apply_type_filter(queryset, media_types): - """Apply OR filter for media types.""" - if not media_types: - return queryset - return queryset.filter(media_type__in=media_types) - - -def _apply_status_filter(queryset, statuses): - """Apply OR filter for statuses.""" - if not statuses: - return queryset - return queryset.filter(status__in=statuses) - - -def _apply_score_filter(queryset, scores): - """Apply OR filter for scores (including 'none' for null scores).""" - if not scores: - return queryset - score_q = Q() - for score in scores: - if score == "none": - score_q |= Q(score__isnull=True) - else: - try: - score_q |= Q(score=int(score)) - except ValueError: - # Skip malformed score values from URL - continue - return queryset.filter(score_q) - - -def _apply_date_and_content_filters(queryset, filters): - """Apply review date, review content, and cover filters.""" - if filters["review_from"]: - # Skip malformed date values from URL - with contextlib.suppress(ValueError, TypeError, ValidationError): - queryset = queryset.filter(review_date__gte=filters["review_from"]) - if filters["review_to"]: - # Skip malformed date values from URL - with contextlib.suppress(ValueError, TypeError, ValidationError): - queryset = queryset.filter(review_date__lte=filters["review_to"]) - if filters["has_review"] == "empty": - queryset = queryset.filter(Q(review__isnull=True) | Q(review="")) - elif filters["has_review"] == "filled": - queryset = queryset.exclude(Q(review__isnull=True) | Q(review="")) - if filters["has_cover"] == "empty": - queryset = queryset.filter(Q(cover__isnull=True) | Q(cover="")) - elif filters["has_cover"] == "filled": - queryset = queryset.exclude(Q(cover__isnull=True) | Q(cover="")) - return queryset - - -def _apply_filters(queryset, filters): - """Apply filters to a queryset and return (queryset, contributor).""" - queryset, contributor = _apply_contributor_filter(queryset, filters["contributor"]) - queryset = _apply_type_filter(queryset, filters["type"]) - queryset = _apply_status_filter(queryset, filters["status"]) - queryset = _apply_score_filter(queryset, filters["score"]) - queryset = _apply_date_and_content_filters(queryset, filters) - return queryset, contributor - - -def _build_media_context(request): - """ - Build and filter media queryset from request parameters. - - Returns a tuple of (page_obj, context_dict) ready for rendering. - This consolidates the common logic used by index and load_more_media views. - """ - view_mode = request.GET.get("view_mode", "grid") - sort_field, sort = _resolve_sorting(request) - filters = _extract_filters(request) - search_query = request.GET.get("search", "").strip() - - # Build queryset based on whether it's a search or not - queryset = _build_search_queryset(search_query) if search_query else Media.objects.all() - - # Apply filters and sorting - queryset, contributor = _apply_filters(queryset, filters) - queryset = queryset.order_by(sort) - - # Pagination: 20 items per page - page_number = request.GET.get("page", 1) - paginator = Paginator(queryset, 20) - page_obj = paginator.get_page(page_number) - - context = { - "media_list": page_obj.object_list, - "page_obj": page_obj, - "view_mode": view_mode, - "sort_field": sort_field, - "sort": sort, - "contributor": contributor, - "filters": filters, - **_get_field_choices(), - } - - return page_obj, context - - @login_required def index(request): """Main view for displaying media list.""" - _, context = _build_media_context(request) + context = build_media_context(request) return render(request, "media.html", context) @@ -278,7 +85,7 @@ def media_delete(request, pk): @login_required def load_more_media(request): """HTMX view: load next page of media items for infinite scrolling.""" - _, context = _build_media_context(request) + context = build_media_context(request) # Return only the items + load more button return render(request, "partials/media-items-page.html", context)