Skip to content

Commit d37a528

Browse files
feat: management commands to create signatories in bulk and delete a signatory (#2986)
1 parent 85e8e2c commit d37a528

File tree

2 files changed

+254
-0
lines changed

2 files changed

+254
-0
lines changed
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
"""Reads the signatories from a CSV file and populates the Signatory model in bulk."""
2+
3+
import csv
4+
import logging
5+
from pathlib import Path
6+
7+
import requests
8+
from django.core.exceptions import ValidationError
9+
from django.core.files.base import ContentFile
10+
from django.core.management.base import BaseCommand
11+
from wagtail.images.models import Image
12+
13+
from cms.models import SignatoryIndexPage, SignatoryPage
14+
15+
logger = logging.getLogger(__name__)
16+
17+
18+
class Command(BaseCommand):
19+
"""Bulk populate signatories from a CSV file."""
20+
21+
help = "Bulk populate signatories from a CSV file"
22+
23+
def add_arguments(self, parser):
24+
"""Parses command line arguments."""
25+
parser.add_argument(
26+
"--csv-file",
27+
type=str,
28+
help="Path to the CSV file containing signatory data",
29+
required=True,
30+
)
31+
parser.add_argument(
32+
"--dry-run",
33+
action="store_true",
34+
help="Show which signatories would be created without making any changes",
35+
)
36+
37+
def handle(self, *args, **options): # noqa: ARG002
38+
"""Handles the command execution."""
39+
csv_file_path = options["csv_file"]
40+
dry_run = options["dry_run"]
41+
42+
if dry_run:
43+
self.stdout.write(self.style.WARNING("[DRY RUN] - No changes will be made"))
44+
try:
45+
with Path(csv_file_path).open("r", newline="", encoding="utf-8") as csvfile:
46+
reader = csv.DictReader(csvfile)
47+
for row_num, row in enumerate(reader, start=2): # Start at 2 for header
48+
self.process_signatory(row, row_num, dry_run)
49+
50+
except FileNotFoundError:
51+
self.stdout.write(self.style.ERROR(f"CSV file not found: {csv_file_path}"))
52+
except Exception as e: # noqa: BLE001
53+
self.stdout.write(self.style.ERROR(f"Error processing CSV: {e!s}"))
54+
55+
def fetch_or_create_signature_image(self, signatory_name, image_url):
56+
"""Fetches an image from a URL and creates a Wagtail Image instance."""
57+
if not image_url:
58+
return None
59+
try:
60+
signature_image_title = f"{signatory_name} Signature"
61+
existing_image = Image.objects.filter(
62+
title__icontains=signature_image_title
63+
).first()
64+
65+
if existing_image:
66+
return existing_image
67+
else:
68+
response = requests.get(image_url, timeout=10)
69+
response.raise_for_status()
70+
71+
# Extract filename from URL or create one
72+
filename = Path(image_url.split("?")[0]).name
73+
if not filename or "." not in filename:
74+
filename = (
75+
f"signatory_{signatory_name.replace(' ', '_').lower()}.jpg"
76+
)
77+
78+
# Create Wagtail Image instance
79+
image_file = ContentFile(response.content, name=filename)
80+
signature_image = Image(
81+
title=f"{signatory_name} Signature", file=image_file
82+
)
83+
signature_image.save()
84+
except Exception as e: # noqa: BLE001
85+
self.stdout.write(
86+
self.style.WARNING(
87+
f"Could not download image for '{signatory_name}': {e!s}"
88+
)
89+
)
90+
return None
91+
else:
92+
return signature_image
93+
94+
def process_signatory(self, row, row_num, dry_run):
95+
"""Processes a single signatory row."""
96+
name = row.get("name", "").strip()
97+
signatory_title = row.get("signatory_title", "").strip()
98+
signatory_image_url = row.get("signatory_image_url", "").strip()
99+
100+
if not name:
101+
self.stdout.write(
102+
self.style.WARNING(f"Row {row_num}: Skipping - no name provided")
103+
)
104+
return
105+
106+
# Check for duplicates
107+
existing_signatory = SignatoryPage.objects.filter(name=name).first()
108+
109+
try:
110+
if dry_run:
111+
if existing_signatory:
112+
self.stdout.write(
113+
self.style.SUCCESS(
114+
f'[DRY RUN] Row {row_num}: Signatory already exists - would skip "{name}"'
115+
)
116+
)
117+
else:
118+
self.stdout.write(
119+
self.style.SUCCESS(
120+
f'[DRY RUN] Row {row_num}: Would create signatory "{name}"'
121+
)
122+
)
123+
else:
124+
signature_image = self.fetch_or_create_signature_image(
125+
name, signatory_image_url
126+
)
127+
128+
if existing_signatory:
129+
self.stdout.write(
130+
self.style.SUCCESS(
131+
f'Row {row_num}: Signatory already exists - skipping "{name}"'
132+
)
133+
)
134+
else:
135+
# Create new signatory
136+
# Download and create image if URL provided
137+
signatory_index_page = SignatoryIndexPage.objects.first()
138+
if not signatory_index_page:
139+
raise ValidationError("No SignatoryIndexPage found in the CMS.") # noqa: EM101, TRY301
140+
141+
signatory_page = SignatoryPage(
142+
name=name,
143+
title_1=signatory_title,
144+
signature_image=signature_image,
145+
)
146+
signatory_index_page.add_child(instance=signatory_page)
147+
signatory_page.save_revision().publish()
148+
149+
self.stdout.write(
150+
self.style.SUCCESS(f'Row {row_num}: Created signatory "{name}"')
151+
)
152+
153+
except ValidationError as e:
154+
self.stdout.write(
155+
self.style.ERROR(f'Row {row_num}: Validation error for "{name}": {e!s}')
156+
)
157+
except Exception as e: # noqa: BLE001
158+
self.stdout.write(
159+
self.style.ERROR(
160+
f'Row {row_num}: Error processing signatory "{name}": {e!s}'
161+
)
162+
)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
from django.core.management.base import BaseCommand
2+
from wagtail.blocks import StreamValue
3+
4+
from cms.models import CertificatePage
5+
6+
7+
class Command(BaseCommand):
8+
"""Django management command to remove a signatory from all certificate pages.
9+
10+
This command finds all certificate pages that contain a specific signatory
11+
and removes that signatory from their signatories field, creating new
12+
revisions and publishing the updated pages.
13+
"""
14+
15+
help = "Remove a signatory from all certificate pages and create new revisions"
16+
17+
def add_arguments(self, parser):
18+
"""Parses command line arguments."""
19+
parser.add_argument(
20+
"--signatory-id",
21+
type=int,
22+
help="ID of the signatory page to remove",
23+
required=True,
24+
)
25+
parser.add_argument(
26+
"--dry-run",
27+
action="store_true",
28+
help="Show which certificate pages would be updated without making changes",
29+
)
30+
31+
def handle(self, *args, **options): # noqa: ARG002
32+
"""Handles the command execution."""
33+
signatory_id = options["signatory_id"]
34+
dry_run = options["dry_run"]
35+
36+
# Find all certificate pages that have this signatory
37+
certificate_pages = CertificatePage.objects.all()
38+
updated_count = 0
39+
40+
if not certificate_pages:
41+
self.stdout.write(
42+
self.style.WARNING(
43+
f"No certificate pages found with signatory ID {signatory_id}"
44+
)
45+
)
46+
return
47+
48+
for cert_page in certificate_pages:
49+
signatory_blocks = list(cert_page.signatories)
50+
filtered_data = [
51+
{
52+
"type": block.block_type,
53+
"value": block.value.id, # pass the page ID, NOT the whole page instance
54+
}
55+
for block in signatory_blocks
56+
if block.value.id != signatory_id
57+
]
58+
59+
if len(filtered_data) != len(signatory_blocks):
60+
if dry_run:
61+
self.stdout.write(
62+
self.style.SUCCESS(
63+
f"[DRY RUN] Would remove signatory from certificate page {cert_page}"
64+
)
65+
)
66+
else:
67+
cert_page.signatories = StreamValue(
68+
cert_page.signatories.stream_block, filtered_data, is_lazy=True
69+
)
70+
cert_page.save_revision().publish()
71+
self.stdout.write(
72+
self.style.SUCCESS(
73+
f"Successfully removed signatory from the certificate page {cert_page}"
74+
)
75+
)
76+
updated_count += 1
77+
78+
if updated_count == 0:
79+
self.stdout.write(
80+
self.style.WARNING(
81+
f"No certificate pages found with signatory ID {signatory_id}"
82+
)
83+
)
84+
else:
85+
action_text = (
86+
"[DRY RUN] Would remove" if dry_run else "Successfully removed"
87+
)
88+
self.stdout.write(
89+
self.style.SUCCESS(
90+
f"{action_text} signatory from {updated_count} certificate pages"
91+
)
92+
)

0 commit comments

Comments
 (0)