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:
+21
-6
@@ -1,11 +1,22 @@
|
||||
from datetime import date
|
||||
from datetime import date as _date
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, field_validator
|
||||
from pydantic import BaseModel, computed_field, field_validator
|
||||
|
||||
|
||||
# ── Program ──────────────────────────────────────────────
|
||||
|
||||
DAY_NAMES = {
|
||||
1: "Monday",
|
||||
2: "Tuesday",
|
||||
3: "Wednesday",
|
||||
4: "Thursday",
|
||||
5: "Friday",
|
||||
6: "Saturday",
|
||||
7: "Sunday",
|
||||
}
|
||||
|
||||
|
||||
class ProgramCreate(BaseModel):
|
||||
day_of_week: int
|
||||
day_label: str
|
||||
@@ -27,19 +38,23 @@ class ProgramUpdate(BaseModel):
|
||||
class ProgramResponse(BaseModel):
|
||||
id: int
|
||||
day_of_week: int
|
||||
day_label: str
|
||||
time: str
|
||||
title: str
|
||||
host: 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}
|
||||
|
||||
|
||||
# ── Event ────────────────────────────────────────────────
|
||||
|
||||
class EventCreate(BaseModel):
|
||||
date: date
|
||||
date: _date
|
||||
title: str
|
||||
description: str
|
||||
location: str
|
||||
@@ -48,7 +63,7 @@ class EventCreate(BaseModel):
|
||||
|
||||
|
||||
class EventUpdate(BaseModel):
|
||||
date: Optional[date] = None
|
||||
date: Optional[_date] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
@@ -59,7 +74,7 @@ class EventUpdate(BaseModel):
|
||||
|
||||
class EventResponse(BaseModel):
|
||||
id: int
|
||||
date: date
|
||||
date: _date
|
||||
title: str
|
||||
description: str
|
||||
location: str
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user