Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
04aae6f
Merge pull request #35 from CoverCensus/dev
jlogan Mar 13, 2026
fda740c
contribution migration
jlogan Mar 13, 2026
717c477
new migration for contributions
jlogan Mar 13, 2026
6de8071
single dashboard for submission and suggestion
jlogan Mar 16, 2026
72ab7a7
edit and delete from Submission
jlogan Mar 16, 2026
24e76c3
edit issue
jlogan Mar 16, 2026
71a04f5
edit 500 issue
jlogan Mar 16, 2026
6d36ac5
editor dashboard
jlogan Mar 16, 2026
f9c2176
user submission and feedback
jlogan Mar 16, 2026
9f77005
delete and town list
jlogan Mar 16, 2026
e9653de
total postmark
jlogan Mar 16, 2026
1ef1aff
total postmark static
jlogan Mar 16, 2026
be24e2b
editor dashboard with contribution detail page
jlogan Mar 17, 2026
74a737d
editor approval issue
jlogan Mar 17, 2026
9f72e99
approval serv issue
jlogan Mar 17, 2026
f13c751
contribution detail page
jlogan Mar 17, 2026
5082b89
editer approval issue and postmark name
jlogan Mar 17, 2026
db43a7b
show suggestion in my Submission
jlogan Mar 17, 2026
24487a5
editor approval 500 error
jlogan Mar 17, 2026
646ad90
resolve internal server error
jlogan Mar 17, 2026
ec012a6
revire and history
jlogan Mar 17, 2026
d06649b
resubmit the rejected postmark
jlogan Mar 18, 2026
1c900fe
editor contribute and edit resubmit
jlogan Mar 18, 2026
9669a26
edit and resubmit responsive
jlogan Mar 18, 2026
3147a09
contributor and editor edits
jlogan Mar 18, 2026
9c9c783
fix(frontend): move contribution prefill useEffect before early retur…
jlogan Mar 19, 2026
06c6ac0
editor edits dimensions
jlogan Mar 19, 2026
70bb04c
remove submit data and added edit data section
jlogan Mar 19, 2026
d4e90e3
responsive and contribution detail
jlogan Mar 19, 2026
4e9d6f2
contribution detail page
jlogan Mar 19, 2026
5d48a7c
contribution responsive section
jlogan Mar 19, 2026
0f88b18
editor approval
jlogan Mar 20, 2026
01726d7
resubmit
jlogan Mar 20, 2026
94eed7a
Edit before resubmitting
jlogan Mar 20, 2026
1f90648
suggest detail images list
jlogan Mar 20, 2026
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
60 changes: 47 additions & 13 deletions backend/common/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,39 @@
from reversion_compare.admin import CompareVersionAdmin

from .models import (
PostalFacility, PostalFacilityIdentity,
AdministrativeUnit, AdministrativeUnitIdentity, AdministrativeUnitResponsibility,
PostalFacility,
PostalFacilityIdentity,
AdministrativeUnit,
AdministrativeUnitIdentity,
AdministrativeUnitResponsibility,
JurisdictionalAffiliation,
PostmarkShape, LetteringStyle, FramingStyle, Color, DateFormat,
Postmark, PostmarkColor, PostmarkDatesSeen, PostmarkSize,
PostmarkValuation, PostmarkPublication, PostmarkPublicationReference,
PostmarkImage, Postcover, PostcoverPostmark, PostcoverImage,
LegacyAbbreviation, LegacyRateLocation, LegacyRateValue,
LegacyParseStep, LegacyUserState, LegacyRawStateDataPendingUpdate, LegacyCover,
AdminCsvUpload, UserLocationAssignment, Contribution,
PostmarkShape,
LetteringStyle,
FramingStyle,
Color,
DateFormat,
Postmark,
PostmarkColor,
PostmarkDatesSeen,
PostmarkSize,
PostmarkValuation,
PostmarkPublication,
PostmarkPublicationReference,
PostmarkImage,
Postcover,
PostcoverPostmark,
PostcoverImage,
LegacyAbbreviation,
LegacyRateLocation,
LegacyRateValue,
LegacyParseStep,
LegacyUserState,
LegacyRawStateDataPendingUpdate,
LegacyCover,
AdminCsvUpload,
UserLocationAssignment,
Contribution,
FAQEntry,
)
from .csv_import import IMPORTERS
from .utils import get_canonical_location_reference_codes
Expand Down Expand Up @@ -1090,11 +1113,14 @@ def __init__(self, *args, **kwargs):
user_location_assignments__user=self.instance
)

# Initialise role based solely on existing group membership so that
# choosing "Contributor" persists even for superusers.
# Initialise role from group membership or from location assignments, so that
# opening a state editor always shows "State Editor" with their assigned locations.
role_initial = ROLE_CONTRIBUTOR
if self.instance.pk and self.instance.groups.filter(name__iexact="State Editors").exists():
role_initial = ROLE_STATE_EDITOR
if self.instance.pk:
if self.instance.groups.filter(name__iexact="State Editors").exists():
role_initial = ROLE_STATE_EDITOR
elif _user_location_table_available() and UserLocationAssignment.objects.filter(user=self.instance).exists():
role_initial = ROLE_STATE_EDITOR
self.fields['role'].initial = role_initial or ROLE_CONTRIBUTOR

def _save_locations(self):
Expand Down Expand Up @@ -1365,4 +1391,12 @@ def reject_contributions(self, request, queryset):
)


@admin.register(FAQEntry)
class FAQEntryAdmin(TimestampedModelAdmin, ReversionAdminBase):
list_display = ("question", "is_active", "display_order")
list_filter = ("is_active",)
search_fields = ("question", "answer")
ordering = ("display_order", "faq_entry_id")


###################################################################################################
7 changes: 6 additions & 1 deletion backend/common/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ def process_view(request, view_func, view_args, view_kwargs):
"/api/login", "/api/login/", "/api/logout", "/api/logout/",
"/api/login-requests", "/api/login-requests/",
)
if path in exempt_paths or path.startswith("/api/admin-csv-uploads/"):
contrib_action = path.startswith("/api/contributions/") and (
path.rstrip("/").endswith("/approve")
or path.rstrip("/").endswith("/reject")
or path.rstrip("/").endswith("/request-revision")
)
if path in exempt_paths or path.startswith("/api/admin-csv-uploads/") or contrib_action:
setattr(view_func, "csrf_exempt", True)
return None

Expand Down
6 changes: 0 additions & 6 deletions backend/common/migrations/0028_contribution_align_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,6 @@
def alter_contributions_table(apps, schema_editor):
from django.db import connection
if connection.vendor != "sqlite":
schema_editor.execute(
"ALTER TABLE Contributions ADD COLUMN ReviewerUserID integer NULL REFERENCES auth_user(id)"
)
schema_editor.execute(
"ALTER TABLE Contributions ALTER COLUMN PostmarkID DROP NOT NULL"
)
return
# SQLite: recreate table to add column and change PostmarkID to nullable
with connection.cursor() as cursor:
Expand Down
52 changes: 52 additions & 0 deletions backend/common/migrations/0030_ensure_contributions_table.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""
Ensure the Contributions table exists in all environments.

Safe to run multiple times and on mixed-case MySQL/MariaDB setups.
Creates the table from the historical Contribution model definition
only if it is currently missing.
"""

from django.conf import settings
from django.db import migrations


def ensure_contributions_table(apps, schema_editor):
connection = schema_editor.connection
with connection.cursor() as cursor:
if connection.vendor == "mysql":
cursor.execute(
"""
SELECT 1
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND LOWER(table_name) = 'contributions'
LIMIT 1
"""
)
if cursor.fetchone():
return
else:
existing = {t.lower() for t in connection.introspection.table_names()}
if "contributions" in existing:
return

Contribution = apps.get_model("common", "Contribution")
schema_editor.create_model(Contribution)


class Migration(migrations.Migration):
# Do not wrap in a transaction; MySQL/MariaDB cannot roll back DDL safely.
atomic = False

dependencies = [
("common", "0029_add_postmark_other_characteristics"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.RunPython(
ensure_contributions_table,
migrations.RunPython.noop,
),
]

36 changes: 36 additions & 0 deletions backend/common/migrations/0031_faqentry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 5.2.7 on 2026-03-16 14:19

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('common', '0030_ensure_contributions_table'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name='FAQEntry',
fields=[
('created_date', models.DateTimeField(auto_now_add=True, db_column='CreatedDate')),
('modified_date', models.DateTimeField(auto_now=True, db_column='ModifiedDate')),
('faq_entry_id', models.AutoField(db_column='FAQEntryID', primary_key=True, serialize=False)),
('question', models.CharField(db_column='Question', max_length=500)),
('answer', models.TextField(db_column='Answer')),
('is_active', models.BooleanField(db_column='IsActive', default=True)),
('display_order', models.PositiveIntegerField(db_column='DisplayOrder', default=0, help_text='Lower numbers appear first.')),
('created_by', models.ForeignKey(db_column='CreatedByUserID', on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL)),
('modified_by', models.ForeignKey(db_column='ModifiedByUserID', on_delete=django.db.models.deletion.PROTECT, related_name='%(class)s_modified', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'FAQ entry',
'verbose_name_plural': 'FAQ entries',
'db_table': 'FAQEntries',
'ordering': ['display_order', 'faq_entry_id'],
},
),
]
140 changes: 140 additions & 0 deletions backend/common/migrations/0032_add_initial_towns_skeleton.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
from django.conf import settings
from django.db import migrations


# NOTE FOR MAINTAINERS:
# ----------------------
# This migration is a SAFE SKELETON to help you bulk-create towns for existing
# states using the new PostalFacility / JurisdictionalAffiliation model.
#
# By default, STATE_TOWNS is EMPTY so the migration is effectively a no-op.
# To use it:
# 1. Fill in STATE_TOWNS below with the towns you want per state.
# Keys should match AdministrativeUnitIdentity.unit_name (e.g. "Alabama").
# 2. Run migrations (python manage.py migrate).
#
# Example:
# STATE_TOWNS = {
# "Alabama": ["Birmingham", "Montgomery"],
# "Alaska": ["Anchorage"],
# }
#
# The migration will, for each (state_name, town_name):
# - Find the AdministrativeUnit whose current identity has unit_name = state_name
# - Create PostalFacility (if needed)
# - Create PostalFacilityIdentity (if needed)
# - Create JurisdictionalAffiliation linking that identity to the state

STATE_TOWNS = {
# "Alabama": ["Birmingham", "Montgomery"],
# "Alaska": ["Anchorage"],
# "Virginia": ["Richmond", "Norfolk"],
}


def create_initial_towns(apps, schema_editor):
if not STATE_TOWNS:
# No configuration → explicit no-op
return

UserModel = apps.get_model(settings.AUTH_USER_MODEL.split(".")[0], settings.AUTH_USER_MODEL.split(".")[1])
AdministrativeUnit = apps.get_model("common", "AdministrativeUnit")
AdministrativeUnitIdentity = apps.get_model("common", "AdministrativeUnitIdentity")
PostalFacility = apps.get_model("common", "PostalFacility")
PostalFacilityIdentity = apps.get_model("common", "PostalFacilityIdentity")
JurisdictionalAffiliation = apps.get_model("common", "JurisdictionalAffiliation")

# Pick a system user to use for created_by / modified_by.
# Prefer a staff superuser; fall back to any user; if none, do nothing.
user = (
UserModel.objects.filter(is_superuser=True).first()
or UserModel.objects.filter(is_staff=True).first()
or UserModel.objects.first()
)
if not user:
return

from datetime import date
from django.utils.text import slugify

for state_name, towns in STATE_TOWNS.items():
cleaned_state = (state_name or "").strip()
if not cleaned_state or not towns:
continue

# Find the AdministrativeUnit whose current identity name matches this state
admin_unit = None
# First, try exact match on current identity
for au in AdministrativeUnit.objects.all():
ident = au.get_current_identity()
if ident and ident.unit_name == cleaned_state:
admin_unit = au
break
if not admin_unit:
# As a fallback, try case-insensitive match on current identity name
for au in AdministrativeUnit.objects.all():
ident = au.get_current_identity()
if ident and ident.unit_name.lower() == cleaned_state.lower():
admin_unit = au
break

if not admin_unit:
# State name not found; skip silently
continue

state_slug = slugify(cleaned_state)[:40] or "unknown"
effective_from = date(1900, 1, 1)

for town_name in towns:
cleaned_town = (town_name or "").strip()
if not cleaned_town:
continue

town_slug = slugify(cleaned_town)[:30] or "unknown"
facility_ref = f"CONTRIB-{town_slug}-{state_slug}"[:50]

facility, _ = PostalFacility.objects.get_or_create(
reference_code=facility_ref,
defaults={
"created_by": user,
"modified_by": user,
},
)

identity, _ = PostalFacilityIdentity.objects.get_or_create(
postal_facility=facility,
effective_from_date=effective_from,
defaults={
"facility_name": cleaned_town[:255],
"facility_type": "POST_OFFICE",
"is_operational": True,
"created_by": user,
"modified_by": user,
},
)

# Link facility identity to state via JurisdictionalAffiliation
JurisdictionalAffiliation.objects.get_or_create(
postal_facility_identity=identity,
administrative_unit=admin_unit,
effective_from_date=effective_from,
defaults={
"effective_to_date": None,
"affiliation_source": "Initial town seed migration",
"created_by": user,
"modified_by": user,
},
)


class Migration(migrations.Migration):

dependencies = [
("common", "0031_faqentry"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.RunPython(create_initial_towns, migrations.RunPython.noop),
]

Loading
Loading