fix: resolve Pydantic date field shadowing causing 422 on event update

The field name 'date' in EventUpdate shadowed the 'date' type from
datetime, causing Pydantic v2 to interpret Optional[date] as
Optional[None] — rejecting all non-null values with a 422 error.

Fix: rename import to 'date as _date' and use '_date' in all
annotations. Also improve error handling in admin forms to properly
display 422 validation error messages.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-19 17:19:03 +00:00
co-authored by Claude
parent b0564ba727
commit bd65455945
4 changed files with 347 additions and 6 deletions
+21 -6
View File
@@ -1,11 +1,22 @@
from datetime import date from datetime import date as _date
from typing import Optional from typing import Optional
from pydantic import BaseModel, field_validator from pydantic import BaseModel, computed_field, field_validator
# ── Program ────────────────────────────────────────────── # ── Program ──────────────────────────────────────────────
DAY_NAMES = {
1: "Monday",
2: "Tuesday",
3: "Wednesday",
4: "Thursday",
5: "Friday",
6: "Saturday",
7: "Sunday",
}
class ProgramCreate(BaseModel): class ProgramCreate(BaseModel):
day_of_week: int day_of_week: int
day_label: str day_label: str
@@ -27,19 +38,23 @@ class ProgramUpdate(BaseModel):
class ProgramResponse(BaseModel): class ProgramResponse(BaseModel):
id: int id: int
day_of_week: int day_of_week: int
day_label: str
time: str time: str
title: str title: str
host: str host: str
genre: str genre: str
@computed_field
@property
def day_label(self) -> str:
return DAY_NAMES.get(self.day_of_week, "Unknown")
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ── Event ──────────────────────────────────────────────── # ── Event ────────────────────────────────────────────────
class EventCreate(BaseModel): class EventCreate(BaseModel):
date: date date: _date
title: str title: str
description: str description: str
location: str location: str
@@ -48,7 +63,7 @@ class EventCreate(BaseModel):
class EventUpdate(BaseModel): class EventUpdate(BaseModel):
date: Optional[date] = None date: Optional[_date] = None
title: Optional[str] = None title: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
location: Optional[str] = None location: Optional[str] = None
@@ -59,7 +74,7 @@ class EventUpdate(BaseModel):
class EventResponse(BaseModel): class EventResponse(BaseModel):
id: int id: int
date: date date: _date
title: str title: str
description: str description: str
location: str location: str
+105
View File
@@ -0,0 +1,105 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { Event } from '../interfaces/event';
import { EventService } from '../services/event.service';
@Component({
selector: 'app-admin-event-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-event-form.component.html',
styleUrl: './admin-event-form.component.scss',
})
export class AdminEventFormComponent implements OnChanges {
private eventService = inject(EventService);
@Input() editItem: Event | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
date = '';
title = '';
description = '';
location = '';
icon = '🎵';
rsvp_url = '';
active = true;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(event: Event): void {
this.id = event.id;
this.date = event.date;
this.title = event.title;
this.description = event.description;
this.location = event.location;
this.icon = event.icon;
this.rsvp_url = event.rsvp_url ?? '';
this.active = event.active;
}
reset(): void {
this.id = null;
this.date = '';
this.title = '';
this.description = '';
this.location = '';
this.icon = '🎵';
this.rsvp_url = '';
this.active = true;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.title || !this.date || !this.description || !this.location) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
date: this.date,
title: this.title,
description: this.description,
location: this.location,
icon: this.icon,
rsvp_url: this.rsvp_url || null,
active: this.active,
};
try {
if (this.id) {
await firstValueFrom(this.eventService.updateEvent(this.id, payload));
} else {
await firstValueFrom(this.eventService.createEvent(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}
@@ -0,0 +1,119 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { Program } from '../interfaces/program';
import { ProgramService } from '../services/program.service';
@Component({
selector: 'app-admin-program-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-program-form.component.html',
styleUrl: './admin-program-form.component.scss',
})
export class AdminProgramFormComponent implements OnChanges {
private programService = inject(ProgramService);
@Input() editItem: Program | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
readonly days = [
{ value: 1, label: 'Monday' },
{ value: 2, label: 'Tuesday' },
{ value: 3, label: 'Wednesday' },
{ value: 4, label: 'Thursday' },
{ value: 5, label: 'Friday' },
{ value: 6, label: 'Saturday' },
{ value: 7, label: 'Sunday' },
];
id: number | null = null;
day_of_week = 1;
day_label = 'Monday';
time = '';
title = '';
host = '';
genre = '';
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
/** Populate form for editing an existing program. */
edit(program: Program): void {
this.id = program.id;
this.day_of_week = program.day_of_week;
this.day_label = program.day_label;
this.time = program.time;
this.title = program.title;
this.host = program.host;
this.genre = program.genre;
}
/** Reset the form for a new entry. */
reset(): void {
this.id = null;
this.day_of_week = 1;
this.day_label = 'Monday';
this.time = '';
this.title = '';
this.host = '';
this.genre = '';
this.error = '';
this.submitted = false;
}
/** Close the dialog without saving. */
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.title || !this.time || !this.host || !this.genre) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const payload = {
day_of_week: this.day_of_week,
day_label: this.day_label,
time: this.time,
title: this.title,
host: this.host,
genre: this.genre,
};
try {
if (this.id) {
await firstValueFrom(this.programService.updateProgram(this.id, payload));
} else {
await firstValueFrom(this.programService.createProgram(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
onDayChange(): void {
const day = this.days.find((d) => d.value === this.day_of_week);
if (day) this.day_label = day.label;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
import { Tier } from '../interfaces/tier';
import { TierService } from '../services/tier.service';
@Component({
selector: 'app-admin-tier-form',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './admin-tier-form.component.html',
styleUrl: './admin-tier-form.component.scss',
})
export class AdminTierFormComponent implements OnChanges {
private tierService = inject(TierService);
@Input() editItem: Tier | null = null;
@Output() saved = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
id: number | null = null;
name = '';
amount = 0;
icon = '⭐';
benefits_text = '';
display_order = 1;
loading = false;
error = '';
submitted = false;
ngOnChanges(changes: SimpleChanges): void {
if (changes['editItem'] && this.editItem) {
this.edit(this.editItem);
}
}
edit(tier: Tier): void {
this.id = tier.id;
this.name = tier.name;
this.amount = tier.amount;
this.icon = tier.icon;
this.benefits_text = tier.benefits.join(', ');
this.display_order = tier.display_order;
}
reset(): void {
this.id = null;
this.name = '';
this.amount = 0;
this.icon = '⭐';
this.benefits_text = '';
this.display_order = 1;
this.error = '';
this.submitted = false;
}
onClose(): void {
this.closed.emit();
this.reset();
}
async onSubmit(): Promise<void> {
this.submitted = true;
if (!this.name || this.amount <= 0) {
this.error = 'Please fill in all required fields.';
return;
}
this.loading = true;
this.error = '';
const benefits = this.benefits_text
.split(',')
.map((b) => b.trim())
.filter((b) => b.length > 0);
const payload = {
name: this.name,
amount: this.amount,
icon: this.icon,
benefits: benefits,
display_order: this.display_order,
};
try {
if (this.id) {
await firstValueFrom(this.tierService.updateTier(this.id, payload));
} else {
await firstValueFrom(this.tierService.createTier(payload));
}
this.saved.emit();
this.reset();
} catch (err: any) {
this.error = this.formatError(err);
} finally {
this.loading = false;
}
}
}