Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,15 @@ jobs:
with:
enable-cache: true

- name: Install gettext (for compilemessages)
run: sudo apt-get update && sudo apt-get install -y gettext

- name: Install dependencies
run: uv sync --dev

- name: Compile translation catalogs
run: uv run python manage.py compilemessages

- name: Run tests
run: uv run pytest

Expand Down
10 changes: 10 additions & 0 deletions Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ COPY . .

ENV PATH="/app/.venv/bin:$PATH"

# Compile translation catalogs into the image. Uses django-admin (not
# manage.py) so it doesn't require settings/env vars (DB, SECRET_KEY, etc.)
# to be configured at build time. gettext (msgfmt) is only needed here, so
# it's removed again afterward to keep the runtime image slim.
RUN apt-get update \
&& apt-get install -y --no-install-recommends gettext \
&& django-admin compilemessages --ignore=".venv/*" \
&& apt-get purge -y --auto-remove gettext \
&& rm -rf /var/lib/apt/lists/*

EXPOSE 8000

# Workers: 2 * CPU_count + 1 is the recommended starting point.
Expand Down
31 changes: 16 additions & 15 deletions comicsdb/forms/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
ModelForm,
ValidationError,
)
from django.utils.translation import gettext_lazy as _
from isbnlib import canonical, is_isbn10, is_isbn13

from comicsdb.autocomplete import (
Expand Down Expand Up @@ -46,7 +47,7 @@ class IssueForm(ModelForm):
widget=SafeAutocompleteWidget(
ac_class=SeriesAutocomplete,
attrs={
"placeholder": "Autocomplete...",
"placeholder": _("Autocomplete..."),
},
),
)
Expand Down Expand Up @@ -94,16 +95,16 @@ class Meta:
"image": ClearableFileInput(),
}
help_texts = {
"alt_number": "Primarily used for legacy numbering for DC and Marvel comics.",
"name": "Separate multiple story titles by a semicolon",
"title": "Only used with Collected Editions like a Trade Paperback.",
"price": "USD for US publishers, GBP for UK publishers",
"reprints": "Search using 'Series Name (Year) #Number' format.",
"foc_date": "This date should be earlier than the store date",
"alt_number": _("Primarily used for legacy numbering for DC and Marvel comics."),
"name": _("Separate multiple story titles by a semicolon"),
"title": _("Only used with Collected Editions like a Trade Paperback."),
"price": _("USD for US publishers, GBP for UK publishers"),
"reprints": _("Search using 'Series Name (Year) #Number' format."),
"foc_date": _("This date should be earlier than the store date"),
}
labels = {
"name": "Story Title",
"title": "Collection Title",
"name": _("Story Title"),
"title": _("Collection Title"),
}

def __init__(self, *args, **kwargs):
Expand All @@ -117,7 +118,7 @@ def __init__(self, *args, **kwargs):
def _validate_date(self, field: str):
form_date = self.cleaned_data[field]
if form_date is not None and form_date.year < MINIMUM_YEAR:
raise ValidationError("Date has a non-valid year.")
raise ValidationError(_("Date has a non-valid year."))
return form_date

def clean_store_date(self):
Expand All @@ -132,7 +133,7 @@ def clean_foc_date(self):
def clean_sku(self):
sku = self.cleaned_data["sku"]
if sku and not sku.isalnum():
raise ValidationError("SKU must be alphanumeric. No spaces or hyphens allowed.")
raise ValidationError(_("SKU must be alphanumeric. No spaces or hyphens allowed."))
return sku

def clean_isbn(self):
Expand All @@ -141,27 +142,27 @@ def clean_isbn(self):
isbn = canonical(data)
if is_isbn10(isbn) or is_isbn13(isbn):
return isbn
raise ValidationError("ISBN is not a valid ISBN-10 or ISBN-13.")
raise ValidationError(_("ISBN is not a valid ISBN-10 or ISBN-13."))
return isbn

def clean_upc(self):
upc = self.cleaned_data["upc"]
if upc and not upc.isdigit():
raise ValidationError("UPC must be numeric. No spaces or hyphens allowed.")
raise ValidationError(_("UPC must be numeric. No spaces or hyphens allowed."))
return upc

def clean_title(self):
collection_title = self.cleaned_data["title"]
if collection_title:
series: Series = self.cleaned_data["series"]
if not series.collection:
raise ValidationError("Collection Title field is not allowed for this series..")
raise ValidationError(_("Collection Title field is not allowed for this series.."))
return collection_title

def clean_arcs(self):
arcs = self.cleaned_data["arcs"]
if arcs:
series: Series = self.cleaned_data["series"]
if series.series_type.id in self.collections:
raise ValidationError("Arcs cannot be added to Trade Paperbacks.")
raise ValidationError(_("Arcs cannot be added to Trade Paperbacks."))
return arcs
27 changes: 14 additions & 13 deletions comicsdb/models/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from django.db.models.signals import pre_save
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from djmoney.models.fields import MoneyField
from PIL import Image
from simple_history.models import HistoricalRecords
Expand Down Expand Up @@ -53,21 +54,21 @@ def get_queryset(self):

class Issue(CommonInfo):
series = models.ForeignKey(Series, on_delete=models.CASCADE, related_name="issues")
name = ArrayField(models.CharField("Story Title", max_length=150), blank=True, default=list)
title = models.CharField("Collection Title", max_length=255, blank=True)
name = ArrayField(models.CharField(_("Story Title"), max_length=150), blank=True, default=list)
title = models.CharField(_("Collection Title"), max_length=255, blank=True)
number = models.CharField(max_length=25)
alt_number = models.CharField("Alternative Number", max_length=25, blank=True)
cover_date = models.DateField("Cover Date")
store_date = models.DateField("In Store Date", null=True, blank=True)
foc_date = models.DateField("Final Order Cutoff Date", null=True, blank=True)
price = MoneyField("Cover Price", max_digits=5, decimal_places=2, blank=True, null=True)
alt_number = models.CharField(_("Alternative Number"), max_length=25, blank=True)
cover_date = models.DateField(_("Cover Date"))
store_date = models.DateField(_("In Store Date"), null=True, blank=True)
foc_date = models.DateField(_("Final Order Cutoff Date"), null=True, blank=True)
price = MoneyField(_("Cover Price"), max_digits=5, decimal_places=2, blank=True, null=True)
rating = models.ForeignKey(Rating, default=1, on_delete=models.SET_DEFAULT)
sku = models.CharField("Distributor SKU", max_length=12, blank=True)
isbn = models.CharField("ISBN", max_length=13, blank=True)
upc = models.CharField("UPC Code", max_length=20, blank=True)
page = models.PositiveSmallIntegerField("Page Count", null=True, blank=True)
image = ImageField("Cover", upload_to="issue/%Y/%m/%d/", blank=True)
cover_hash = models.CharField("Cover Hash", max_length=16, blank=True)
sku = models.CharField(_("Distributor SKU"), max_length=12, blank=True)
isbn = models.CharField(_("ISBN"), max_length=13, blank=True)
upc = models.CharField(_("UPC Code"), max_length=20, blank=True)
page = models.PositiveSmallIntegerField(_("Page Count"), null=True, blank=True)
image = ImageField(_("Cover"), upload_to="issue/%Y/%m/%d/", blank=True)
cover_hash = models.CharField(_("Cover Hash"), max_length=16, blank=True)
arcs = models.ManyToManyField(Arc, blank=True, related_name="issues")
creators = models.ManyToManyField(Creator, through="Credits", blank=True, related_name="issues")
characters = models.ManyToManyField(Character, blank=True, related_name="issues")
Expand Down
100 changes: 70 additions & 30 deletions comicsdb/views/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from django.shortcuts import get_object_or_404
from django.template.loader import render_to_string
from django.urls import reverse, reverse_lazy
from django.utils.translation import gettext as _
from django.views import View
from django.views.generic import DetailView, ListView
from django.views.generic.edit import CreateView, DeleteView, UpdateView
Expand Down Expand Up @@ -447,31 +448,40 @@ def post(self, request, slug):
if issue.series.publisher.name in excluded_publishers:
messages.error(
request,
f"Credit duplication is not allowed for {issue.series.publisher.name} issues.",
_("Credit duplication is not allowed for %(publisher)s issues.")
% {"publisher": issue.series.publisher.name},
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Check if issue already has credits
if issue.credits_set.exists():
messages.info(
request,
f"{issue} already has credits assigned. "
"Duplicate operation cancelled to preserve existing data.",
_(
"%(issue)s already has credits assigned. "
"Duplicate operation cancelled to preserve existing data."
)
% {"issue": issue},
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Try to get the previous issue by cover_date
try:
previous_issue = issue.get_previous_by_cover_date(series=issue.series)
except ObjectDoesNotExist:
messages.warning(request, f"No previous issue found for {issue}.")
messages.warning(
request, _("No previous issue found for %(issue)s.") % {"issue": issue}
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Get all credits from the previous issue
previous_credits = Credits.objects.filter(issue=previous_issue).prefetch_related("role")

if not previous_credits.exists():
messages.info(request, f"No credits found in {previous_issue} to duplicate.")
messages.info(
request,
_("No credits found in %(issue)s to duplicate.") % {"issue": previous_issue},
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Get the Cover role to check for variant cover credits
Expand Down Expand Up @@ -521,20 +531,35 @@ def post(self, request, slug):

# Provide appropriate feedback based on what was duplicated
if credits_count > 0:
message = f"Successfully duplicated {credits_count} credit(s) from {previous_issue}."
if skipped_count > 0:
message += f" Skipped {skipped_count} variant cover credit(s)."
message = _(
"Successfully duplicated %(credits)s credit(s) from %(issue)s."
" Skipped %(skipped)s variant cover credit(s)."
) % {
"credits": credits_count,
"issue": previous_issue,
"skipped": skipped_count,
}
else:
message = _("Successfully duplicated %(credits)s credit(s) from %(issue)s.") % {
"credits": credits_count,
"issue": previous_issue,
}
messages.success(request, message)
elif skipped_count > 0:
messages.info(
request,
(
f"No credits were duplicated. Skipped {skipped_count} variant cover credit(s)"
f" from {previous_issue}."
),
_(
"No credits were duplicated. Skipped %(skipped)s variant cover credit(s)"
" from %(issue)s."
)
% {"skipped": skipped_count, "issue": previous_issue},
)
else:
messages.info(request, f"No credits found in {previous_issue} to duplicate.")
messages.info(
request,
_("No credits found in %(issue)s to duplicate.") % {"issue": previous_issue},
)

return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

Expand All @@ -545,7 +570,7 @@ class IssueReprintSyncView(LoginRequiredMixin, View):
to the current issue.
"""

def post(self, request, slug): # noqa: PLR0912
def post(self, request, slug): # noqa: PLR0912, PLR0915
"""
Add characters, teams, and story titles from all reprinted issues to the current issue.
Only works for Trade Paperback, Omnibus, and Hardcover series types.
Expand All @@ -572,26 +597,34 @@ def post(self, request, slug): # noqa: PLR0912
# Check if series type is Trade Paperback or Omnibus
allowed_types = ["Trade Paperback", "Omnibus", "Hardcover"]
if issue.series.series_type.name not in allowed_types:
type_list = f"{', '.join(allowed_types[:-1])} and {allowed_types[-1]}"
messages.error(
request,
f"This function only works for {', '.join(allowed_types[:-1])} "
f"and {allowed_types[-1]} series types. "
f"This issue is of type '{issue.series.series_type.name}'.",
_(
"This function only works for %(types)s series types. "
"This issue is of type '%(current_type)s'."
)
% {"types": type_list, "current_type": issue.series.series_type.name},
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Check if there are any reprints
if not issue.reprints.exists():
messages.warning(request, f"No reprinted issues found for {issue}.")
messages.warning(
request, _("No reprinted issues found for %(issue)s.") % {"issue": issue}
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Check if issue already has characters, teams, or stories
has_stories = issue.name and len(issue.name) > 0
if issue.characters.exists() or issue.teams.exists() or has_stories:
messages.info(
request,
f"{issue} already has characters, teams, or stories assigned. "
"Sync operation cancelled to preserve existing data.",
_(
"%(issue)s already has characters, teams, or stories assigned. "
"Sync operation cancelled to preserve existing data."
)
% {"issue": issue},
)
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

Expand Down Expand Up @@ -619,16 +652,18 @@ def post(self, request, slug): # noqa: PLR0912

# Inform user if any reprints were skipped
if skipped_reprints:
reprint_names = ", ".join(skipped_reprints[:3])
if len(skipped_reprints) > 3: # noqa: PLR2004
reprint_names += "..."
messages.warning(
request,
f"Skipped {len(skipped_reprints)} reprinted issue(s) with multiple story titles: "
f"{', '.join(skipped_reprints[:3])}"
f"{'...' if len(skipped_reprints) > 3 else ''}", # noqa: PLR2004
_("Skipped %(count)s reprinted issue(s) with multiple story titles: %(names)s")
% {"count": len(skipped_reprints), "names": reprint_names},
)

# Check if we found any characters or teams to add
if not characters_to_add and not teams_to_add:
messages.info(request, "No characters or teams found in the reprinted issues.")
messages.info(request, _("No characters or teams found in the reprinted issues."))
return HttpResponseRedirect(reverse("issue:detail", args=[slug]))

# Add all characters, teams, and stories from reprints
Expand Down Expand Up @@ -668,18 +703,23 @@ def post(self, request, slug): # noqa: PLR0912
# Provide feedback
message_parts = []
if characters_to_add:
message_parts.append(f"{len(characters_to_add)} character(s)")
message_parts.append(_("%(count)s character(s)") % {"count": len(characters_to_add)})
if teams_to_add:
message_parts.append(f"{len(teams_to_add)} team(s)")
message_parts.append(_("%(count)s team(s)") % {"count": len(teams_to_add)})
if stories_to_add:
message_parts.append(f"{len(stories_to_add)} story title(s)")
message_parts.append(_("%(count)s story title(s)") % {"count": len(stories_to_add)})

if len(message_parts) > 1:
parts_text = _("%(first)s and %(last)s") % {
"first": ", ".join(message_parts[:-1]),
"last": message_parts[-1],
}
else:
parts_text = message_parts[0]

messages.success(
request,
f"Successfully added {', '.join(message_parts[:-1])} and "
f"{message_parts[-1]} from reprinted issues."
if len(message_parts) > 1
else f"Successfully added {message_parts[0]} from reprinted issues.",
_("Successfully added %(parts)s from reprinted issues.") % {"parts": parts_text},
)

return HttpResponseRedirect(reverse("issue:detail", args=[slug]))
Expand Down
Loading
Loading