Compare commits

..
1 Commits
Author SHA1 Message Date
westfarn 3999b007a5 Add Django unit tests for public pages, forms, models, Stripe, and auth.
CI / test (pull_request) Successful in 3s
Unit Tests / test (pull_request) Successful in 3s
Gives CI/deploy workflows something real to run so PRs and master pushes are gated on coverage of core schasite behavior.
2026-07-14 03:47:50 -07:00
33 changed files with 238 additions and 1061 deletions
-3
View File
@@ -23,8 +23,5 @@ jobs:
env: env:
DJANGO_ENV: dev DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key DJANGO_SECRET_KEY: test-secret-key
# Explicitly clear DB vars so host/prod DATABASE_URL cannot leak in.
DATABASE_URL: ""
DB_HOST: ""
run: | run: |
uv run python manage.py test uv run python manage.py test
+3 -15
View File
@@ -20,27 +20,15 @@ jobs:
- name: Build Docker image - name: Build Docker image
run: docker compose build run: docker compose build
# Ephemeral local Postgres only — never inherit host DATABASE_URL (prod).
- name: Run containerized tests - name: Run containerized tests
run: | run: |
set -euo pipefail docker compose up -d db
# Drop host/prod DB secrets so compose cannot interpolate them. docker compose run --rm --entrypoint "" \
unset DATABASE_URL DB_HOST DB_NAME DB_USER DB_PASSWORD DB_PORT \
COMPOSE_DATABASE_URL DJANGO_ENV DJANGO_SECRET_KEY DJANGO_DEBUG \
DJANGO_ALLOWED_HOSTS || true
PROJECT="scha-ci-${{ gitea.event.workflow_run.head_sha }}"
cleanup() { docker compose -p "$PROJECT" down -v --remove-orphans || true; }
trap cleanup EXIT
docker compose -p "$PROJECT" up -d --wait db
docker compose -p "$PROJECT" run --rm --no-deps --entrypoint "" \
-e DJANGO_ENV=dev \ -e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \ -e DJANGO_SECRET_KEY=test-secret-key \
-e DJANGO_DEBUG=true \
-e DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,testserver \
-e DATABASE_URL=postgres://scha:scha@db:5432/scha \ -e DATABASE_URL=postgres://scha:scha@db:5432/scha \
web uv run python manage.py test web uv run python manage.py test
docker compose down
deploy: deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
-3
View File
@@ -25,8 +25,5 @@ jobs:
env: env:
DJANGO_ENV: dev DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key DJANGO_SECRET_KEY: test-secret-key
# Explicitly clear DB vars so host/prod DATABASE_URL cannot leak in.
DATABASE_URL: ""
DB_HOST: ""
run: | run: |
uv run python manage.py test uv run python manage.py test
+2 -3
View File
@@ -27,8 +27,7 @@ Without `DATABASE_URL` / `DB_HOST`, settings fall back to SQLite (`db.sqlite3`).
docker compose up --build docker compose up --build
``` ```
App: http://localhost:8000 — Postgres via bundled `db` (`postgres://scha:scha@db:5432/scha`). App: http://localhost:8000 — Postgres via `DATABASE_URL=postgres://scha:scha@db:5432/scha`.
Compose does **not** read host `DATABASE_URL` (avoids CI/prod leaks); override with `COMPOSE_DATABASE_URL` if needed.
## Environment variables ## Environment variables
@@ -70,7 +69,7 @@ Validate with:
|----------|---------|--------| |----------|---------|--------|
| `unittests.yml` | push + PR → `master` | `uv sync` + `manage.py test` | | `unittests.yml` | push + PR → `master` | `uv sync` + `manage.py test` |
| `ci.yml` | PR → `master` | same unit tests | | `ci.yml` | PR → `master` | same unit tests |
| `deploy.yml` | after Unit Tests succeeds on `master` **push** | docker build + tests on **ephemeral compose Postgres**`deploy.sh` | | `deploy.yml` | after Unit Tests succeeds on `master` **push** | docker build/test → `deploy.sh` |
Deploy never runs on PRs. Deploy never runs on PRs.
+1 -5
View File
@@ -20,16 +20,12 @@ services:
- "8000:8000" - "8000:8000"
# No required env_file — CI has no .env. Defaults below; for local secrets: # No required env_file — CI has no .env. Defaults below; for local secrets:
# docker compose --env-file .env up # docker compose --env-file .env up
#
# Do NOT interpolate ${DATABASE_URL} here. On the Act runner / control node that
# var often points at shared prod/beta Postgres; compose would bake it into
# containerized tests. Use COMPOSE_DATABASE_URL only if you need to override.
environment: environment:
DJANGO_ENV: ${DJANGO_ENV:-dev} DJANGO_ENV: ${DJANGO_ENV:-dev}
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me} DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true} DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${COMPOSE_DATABASE_URL:-postgres://scha:scha@db:5432/scha} DATABASE_URL: ${DATABASE_URL:-postgres://scha:scha@db:5432/scha}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
+1 -1
View File
@@ -5,7 +5,7 @@ description = "Django site for SCHA Wheaton"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"django>=6.0,<7", "django>=5.2,<6",
"django-phonenumber-field>=8.1.0", "django-phonenumber-field>=8.1.0",
"django-recaptcha>=4.1.0", "django-recaptcha>=4.1.0",
"gunicorn>=23.0.0", "gunicorn>=23.0.0",
+123 -289
View File
@@ -1,13 +1,4 @@
from django.contrib import admin from django.contrib import admin
from django.db.models import Exists, OuterRef, Q
from django.http import HttpResponse
from django.shortcuts import render
from django.urls import reverse
from django.utils import timezone
from datetime import datetime
import csv
import io
from .models import ( from .models import (
UsefulLinks, UsefulLinks,
Membership, Membership,
@@ -20,116 +11,11 @@ from .models import (
Payments, Payments,
SCHAOfficer, SCHAOfficer,
) )
from django.http import HttpResponse
from datetime import datetime
from .forms import PaymentImport from .forms import PaymentImport
# Register your models here.
def _current_year():
return timezone.now().year
def person_payment_exists(year=None):
"""True when this MembershipPerson has a payment in `year` (email or FK)."""
year = _current_year() if year is None else year
email_match = (
Q(email__iexact=OuterRef("email")) & Q(email__isnull=False) & ~Q(email="")
)
return Exists(
Payments.objects.filter(date__year=year).filter(
Q(person_id=OuterRef("pk")) | email_match
)
)
def membership_payment_exists(year=None):
"""True when any person on this membership has a payment in `year`."""
return Exists(
MembershipPerson.objects.filter(membership_id=OuterRef("pk")).filter(
person_payment_exists(year)
)
)
def _available_years(model, field_name):
years = {d.year for d in model.objects.dates(field_name, "year")}
years.add(_current_year())
return sorted(years, reverse=True)
def _apply_year_filter(queryset, field_name, year):
if not year or year == "all":
return queryset
try:
year_int = int(year)
except (TypeError, ValueError):
return queryset
return queryset.filter(**{f"{field_name}__year": year_int})
def _csv_response(filename_suffix, header, rows):
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(header)
writer.writerows(rows)
filename = datetime.now().strftime("%Y_%m_%d_%H_%M_%S") + filename_suffix
response = HttpResponse(buf.getvalue(), content_type="text/csv")
response["Content-Disposition"] = f"attachment; filename={filename}"
return response
def _year_suffix(year):
if year and year != "all":
return f"_{year}"
return ""
def _csv_year_form_response(
modeladmin, request, *, action_name, year_field, help_text
):
opts = modeladmin.model._meta
return render(
request,
"admin/csv_year_export.html",
{
"opts": opts,
"title": "Download CSV",
"action_name": action_name,
"years": _available_years(modeladmin.model, year_field),
"default_year": _current_year(),
"help_text": help_text,
"selected_ids": request.POST.getlist("_selected_action"),
"select_across": request.POST.get("select_across", "0"),
"index": request.POST.get("index", "0"),
"changelist_url": reverse(
f"admin:{opts.app_label}_{opts.model_name}_changelist"
),
},
)
class CreatedYearListFilter(admin.SimpleListFilter):
title = "year"
parameter_name = "year"
def lookups(self, request, model_admin):
return [(year, str(year)) for year in _available_years(model_admin.model, "created")]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(created__year=self.value())
return queryset
class PaymentYearListFilter(admin.SimpleListFilter):
title = "year"
parameter_name = "year"
def lookups(self, request, model_admin):
return [(year, str(year)) for year in _available_years(model_admin.model, "date")]
def queryset(self, request, queryset):
if self.value():
return queryset.filter(date__year=self.value())
return queryset
class UsefulLinksAdmin(admin.ModelAdmin): class UsefulLinksAdmin(admin.ModelAdmin):
@@ -145,17 +31,7 @@ class MembershipAddressInline(admin.TabularInline):
class MembershipPersonInline(admin.TabularInline): class MembershipPersonInline(admin.TabularInline):
model = MembershipPerson model = MembershipPerson
extra = 1 extra = 1
readonly_fields = ("id", "has_payment") readonly_fields = ("id",)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.annotate(_has_payment=person_payment_exists())
@admin.display(description="Paid this year", boolean=True)
def has_payment(self, obj):
if not obj.pk:
return False
return bool(getattr(obj, "_has_payment", False))
class MembershipCommiteeInline(admin.TabularInline): class MembershipCommiteeInline(admin.TabularInline):
@@ -171,104 +47,101 @@ class MembershipServicesInline(admin.TabularInline):
def download_csv_by_members(modelAdmin, request, queryset): def download_csv_by_members(modelAdmin, request, queryset):
if "apply" not in request.POST: import csv
return _csv_year_form_response( import io as StringIO
modelAdmin,
request, def stream_csv(queryset):
action_name="download_csv_by_members", csvfile = StringIO.StringIO()
year_field="created", writer = csv.writer(csvfile)
help_text="Choose a year to include. Memberships are filtered by the year the record was created.", writer.writerow(
[
"address_1",
"city",
"state",
"zip_code",
"first_name",
"last_name",
"email",
"phone_number",
]
)
for q in queryset:
people = [
item for item in MembershipPerson.objects.filter(membership_id=q.id)
]
for person in people:
writer.writerow(
[
q.addressmodel1.address_1,
q.addressmodel1.city,
q.addressmodel1.state,
q.addressmodel1.zip_code,
person.first_name,
person.last_name,
person.email,
person.phone_number,
]
)
yield csvfile.getvalue()
now = datetime.now()
filename = now.strftime("%Y_%m_%d_%H_%M_%S") + "_scha_member_by_member.csv"
response = HttpResponse(stream_csv(queryset), content_type="text/csv")
response["Content-Disposition"] = "attachment; filename={}".format(filename)
return response
def download_csv_by_address(modeladmin, request, queryset):
import csv
import io as StringIO
def stream_csv(queryset):
csvfile = StringIO.StringIO()
writer = csv.writer(csvfile)
writer.writerow(
[
"address_1",
"city",
"state",
"zip_code",
"person_1_email",
"person_1_phone",
"person_1_first_name",
"person_1_last_name",
"person_2_email",
"person_2_phone",
"person_2_first_name",
"person_2_last_name",
]
) )
year = request.POST.get("year", "all") for q in queryset:
queryset = _apply_year_filter(queryset, "created", year) people = [
rows = [] item for item in MembershipPerson.objects.filter(membership_id=q.id)
for q in queryset: ]
people = MembershipPerson.objects.filter(membership_id=q.id) writer.writerow(
for person in people:
rows.append(
[ [
q.addressmodel1.address_1, q.addressmodel1.address_1,
q.addressmodel1.city, q.addressmodel1.city,
q.addressmodel1.state, q.addressmodel1.state,
q.addressmodel1.zip_code, q.addressmodel1.zip_code,
person.first_name, people[0].email if len(people) > 0 else "",
person.last_name, people[0].phone_number if len(people) > 0 else "",
person.email, people[0].first_name if len(people) > 0 else "",
person.phone_number, people[0].last_name if len(people) > 0 else "",
people[1].email if len(people) > 1 else "",
people[1].phone_number if len(people) > 1 else "",
people[1].first_name if len(people) > 1 else "",
people[1].last_name if len(people) > 1 else "",
] ]
) )
return _csv_response( yield csvfile.getvalue()
f"_scha_member_by_member{_year_suffix(year)}.csv",
[
"address_1",
"city",
"state",
"zip_code",
"first_name",
"last_name",
"email",
"phone_number",
],
rows,
)
now = datetime.now()
download_csv_by_members.short_description = "Download CSV by member" filename = now.strftime("%Y_%m_%d_%H_%M_%S") + "_scha_member_by_address.csv"
response = HttpResponse(stream_csv(queryset), content_type="text/csv")
response["Content-Disposition"] = "attachment; filename={}".format(filename)
def download_csv_by_address(modeladmin, request, queryset): return response
if "apply" not in request.POST:
return _csv_year_form_response(
modeladmin,
request,
action_name="download_csv_by_address",
year_field="created",
help_text="Choose a year to include. Memberships are filtered by the year the record was created.",
)
year = request.POST.get("year", "all")
queryset = _apply_year_filter(queryset, "created", year)
rows = []
for q in queryset:
people = list(MembershipPerson.objects.filter(membership_id=q.id))
rows.append(
[
q.addressmodel1.address_1,
q.addressmodel1.city,
q.addressmodel1.state,
q.addressmodel1.zip_code,
people[0].email if len(people) > 0 else "",
people[0].phone_number if len(people) > 0 else "",
people[0].first_name if len(people) > 0 else "",
people[0].last_name if len(people) > 0 else "",
people[1].email if len(people) > 1 else "",
people[1].phone_number if len(people) > 1 else "",
people[1].first_name if len(people) > 1 else "",
people[1].last_name if len(people) > 1 else "",
]
)
return _csv_response(
f"_scha_member_by_address{_year_suffix(year)}.csv",
[
"address_1",
"city",
"state",
"zip_code",
"person_1_email",
"person_1_phone",
"person_1_first_name",
"person_1_last_name",
"person_2_email",
"person_2_phone",
"person_2_first_name",
"person_2_last_name",
],
rows,
)
download_csv_by_address.short_description = "Download CSV by address"
class MembershipAdmin(admin.ModelAdmin): class MembershipAdmin(admin.ModelAdmin):
@@ -278,18 +151,8 @@ class MembershipAdmin(admin.ModelAdmin):
MembershipCommiteeInline, MembershipCommiteeInline,
MembershipServicesInline, MembershipServicesInline,
] ]
list_display = ["get_address_str", "get_person_1", "has_payment"]
list_filter = [CreatedYearListFilter]
actions = [download_csv_by_address, download_csv_by_members] actions = [download_csv_by_address, download_csv_by_members]
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.annotate(_has_payment=membership_payment_exists())
@admin.display(description="Paid this year", boolean=True, ordering="_has_payment")
def has_payment(self, obj):
return bool(getattr(obj, "_has_payment", False))
class CalendarEventAddressInline(admin.TabularInline): class CalendarEventAddressInline(admin.TabularInline):
model = CalendarEventAddressModel model = CalendarEventAddressModel
@@ -302,23 +165,10 @@ class CalendarEventAdmin(admin.ModelAdmin):
list_display = [ list_display = [
"event_name", "event_name",
"start_date", "start_date",
"start_time",
"end_date", "end_date",
"end_time",
"coordinator_email", "coordinator_email",
"event_link_name", "event_link_name",
] ]
fields = [
"event_name",
"start_date",
"start_time",
"end_date",
"end_time",
"location_name",
"coordinator_email",
"event_link_name",
"event_url",
]
class AddressModelAdmin(admin.ModelAdmin): class AddressModelAdmin(admin.ModelAdmin):
@@ -326,17 +176,7 @@ class AddressModelAdmin(admin.ModelAdmin):
class MembershipPersonAdmin(admin.ModelAdmin): class MembershipPersonAdmin(admin.ModelAdmin):
list_display = ["first_name", "last_name", "email", "phone_number", "has_payment"] pass
search_fields = ["email", "first_name", "last_name"]
list_filter = [CreatedYearListFilter]
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.annotate(_has_payment=person_payment_exists())
@admin.display(description="Paid this year", boolean=True, ordering="_has_payment")
def has_payment(self, obj):
return bool(getattr(obj, "_has_payment", False))
class MembershipCommitteeAdmin(admin.ModelAdmin): class MembershipCommitteeAdmin(admin.ModelAdmin):
@@ -352,58 +192,51 @@ class CalendarEventAddressModelAdmin(admin.ModelAdmin):
def download_payments(modelAdmin, request, queryset): def download_payments(modelAdmin, request, queryset):
if "apply" not in request.POST: import csv
return _csv_year_form_response( import io as StringIO
modelAdmin,
request, def stream_payment_csv(queryset):
action_name="download_payments", csvfile = StringIO.StringIO()
year_field="date", writer = csv.writer(csvfile)
help_text="Choose a year to include. Payments are filtered by payment date.", writer.writerow(
["email", "date", "status", "first_name", "last_name", "phone_number"]
) )
for q in queryset:
first_name = ""
last_name = ""
phone_number = ""
if q.person:
first_name = q.person.first_name if q.person.first_name else ""
last_name = q.person.last_name if q.person.last_name else ""
phone_number = q.person.phone_number if q.person.phone_number else ""
writer.writerow(
[
q.email,
q.date,
q.status,
first_name,
last_name,
phone_number,
]
)
yield csvfile.getvalue()
year = request.POST.get("year", "all") now = datetime.now()
queryset = _apply_year_filter(queryset, "date", year) filename = now.strftime("%Y_%m_%d_%H_%M_%S") + "_scha_payments_by_member.csv"
rows = [] response = HttpResponse(stream_payment_csv(queryset), content_type="text/csv")
for q in queryset: response["Content-Disposition"] = "attachment; filename={}".format(filename)
first_name = "" return response
last_name = ""
phone_number = ""
if q.person:
first_name = q.person.first_name if q.person.first_name else ""
last_name = q.person.last_name if q.person.last_name else ""
phone_number = q.person.phone_number if q.person.phone_number else ""
rows.append(
[
q.email,
q.date,
q.status,
first_name,
last_name,
phone_number,
]
)
return _csv_response(
f"_scha_payments_by_member{_year_suffix(year)}.csv",
["email", "date", "status", "first_name", "last_name", "phone_number"],
rows,
)
download_payments.short_description = "Download payments CSV"
class PaymentsAdmin(admin.ModelAdmin): class PaymentsAdmin(admin.ModelAdmin):
list_display = ["date", "status", "email"] list_display = ["date", "status", "email"]
search_fields = ["email"] search_fields = ["email"]
list_filter = [PaymentYearListFilter]
actions = [download_payments] actions = [download_payments]
form = PaymentImport form = PaymentImport
class SCHAOfficerAdmin(admin.ModelAdmin): class SCHAOfficerAdmin(admin.ModelAdmin):
list_display = ["position", "name", "email"] list_display = ["position", "name", "email"]
admin.site.register(UsefulLinks, UsefulLinksAdmin) admin.site.register(UsefulLinks, UsefulLinksAdmin)
admin.site.register(Membership, MembershipAdmin) admin.site.register(Membership, MembershipAdmin)
admin.site.register(CalendarEvent, CalendarEventAdmin) admin.site.register(CalendarEvent, CalendarEventAdmin)
@@ -414,3 +247,4 @@ admin.site.register(MembershipServices, MembershipServicesAdmin)
admin.site.register(CalendarEventAddressModel, CalendarEventAddressModelAdmin) admin.site.register(CalendarEventAddressModel, CalendarEventAddressModelAdmin)
admin.site.register(Payments, PaymentsAdmin) admin.site.register(Payments, PaymentsAdmin)
admin.site.register(SCHAOfficer, SCHAOfficerAdmin) admin.site.register(SCHAOfficer, SCHAOfficerAdmin)
+5 -1
View File
@@ -19,7 +19,11 @@ class CaptchaForm(forms.Form):
captcha = ReCaptchaField( captcha = ReCaptchaField(
public_key=settings.RECAPTCHA_PUBLIC_KEY, public_key=settings.RECAPTCHA_PUBLIC_KEY,
private_key=settings.RECAPTCHA_PRIVATE_KEY, private_key=settings.RECAPTCHA_PRIVATE_KEY,
widget=ReCaptchaV3(required_score=0.85), widget=ReCaptchaV3(
attrs={
'required_score':0.85,
}
),
) )
@@ -1,64 +0,0 @@
# Generated by Django 5.2.16 on 2026-07-14 12:06
import datetime
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schasite', '0010_communityparks_communitypost_communityschools_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='payments',
name='date',
field=models.DateField(default=datetime.datetime(2026, 7, 14, 12, 6, 52, 688298, tzinfo=datetime.timezone.utc)),
),
migrations.CreateModel(
name='CommunityMember',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('membership_person', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='schasite.membershipperson')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='communitycomment',
name='author',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='schasite.communitymember'),
),
migrations.AddField(
model_name='communitypost',
name='author',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='schasite.communitymember'),
),
migrations.AddField(
model_name='communitypostreports',
name='reporter',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='schasite.communitymember'),
),
migrations.CreateModel(
name='CommunityPostLikes',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created', models.DateTimeField(default=django.utils.timezone.now)),
('last_modified', models.DateTimeField(default=django.utils.timezone.now)),
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='schasite.communitypost')),
('reporter', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='schasite.communitymember')),
],
options={
'abstract': False,
},
),
]
@@ -1,19 +0,0 @@
# Generated by Django 6.0.7 on 2026-07-14 12:13
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schasite', '0011_alter_payments_date_communitymember_and_more'),
]
operations = [
migrations.AlterField(
model_name='payments',
name='date',
field=models.DateField(default=django.utils.timezone.now),
),
]
@@ -1,23 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-04 17:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('schasite', '0012_alter_payments_date'),
]
operations = [
migrations.AddField(
model_name='calendarevent',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AddField(
model_name='calendarevent',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
]
+6 -21
View File
@@ -12,13 +12,13 @@ class TimeInfoBase(models.Model):
class Meta: class Meta:
abstract = True abstract = True
def save(self, **kwargs): def save(self, *args, **kwargs):
if not kwargs.pop("skip_last_modified", False) and not hasattr(self, "skip_last_modified"): if not kwargs.pop("skip_last_modified", False) and not hasattr(self, "skip_last_modified"):
self.last_modified = timezone.now() self.last_modified = timezone.now()
if kwargs.get("update_fields") is not None: if kwargs.get("update_fields") is not None:
kwargs["update_fields"] = list({*kwargs["update_fields"], "last_modified"}) kwargs["update_fields"] = list({*kwargs["update_fields"], "last_modified"})
super().save(**kwargs) super().save(*args, **kwargs)
# Create your models here. # Create your models here.
class UsefulLinks(TimeInfoBase): class UsefulLinks(TimeInfoBase):
@@ -66,8 +66,6 @@ class CalendarEvent(TimeInfoBase):
event_name = models.CharField(max_length=256) event_name = models.CharField(max_length=256)
start_date = models.DateField(blank=True, null=True) start_date = models.DateField(blank=True, null=True)
end_date = models.DateField(blank=True, null=True) end_date = models.DateField(blank=True, null=True)
start_time = models.TimeField(blank=True, null=True)
end_time = models.TimeField(blank=True, null=True)
location_name = models.CharField(max_length=256, blank=True, null=True) location_name = models.CharField(max_length=256, blank=True, null=True)
coordinator_email = models.EmailField(max_length=256, blank=True, null=True) coordinator_email = models.EmailField(max_length=256, blank=True, null=True)
event_link_name = models.CharField(max_length=64, blank=True, null=True) event_link_name = models.CharField(max_length=64, blank=True, null=True)
@@ -92,19 +90,6 @@ class CalendarEvent(TimeInfoBase):
def no_date(self): def no_date(self):
return not self.has_date() return not self.has_date()
def time_range_display(self):
"""Format start/end times for templates, e.g. '11:00 AM - 3:00 PM'."""
from django.utils.formats import time_format
if not self.start_time and not self.end_time:
return ""
start = time_format(self.start_time, "g:i A") if self.start_time else ""
end = time_format(self.end_time, "g:i A") if self.end_time else ""
if start and end:
return f"{start} - {end}"
return start or end
class CalendarEventAddressModel(TimeInfoBase): class CalendarEventAddressModel(TimeInfoBase):
calendar_event = models.OneToOneField(CalendarEvent, on_delete=models.CASCADE) calendar_event = models.OneToOneField(CalendarEvent, on_delete=models.CASCADE)
@@ -163,7 +148,7 @@ class MembershipServices(TimeInfoBase):
class Payments(TimeInfoBase): class Payments(TimeInfoBase):
date = models.DateField(default=timezone.now) date = models.DateField(default=timezone.now())
status = models.CharField(default="Completed", max_length=256) status = models.CharField(default="Completed", max_length=256)
email = models.EmailField(blank=True, null=True) email = models.EmailField(blank=True, null=True)
person = models.ForeignKey( person = models.ForeignKey(
@@ -209,12 +194,12 @@ class CommunityPost(TimeInfoBase):
category = models.CharField(max_length=255) category = models.CharField(max_length=255)
content = models.CharField(max_length=1024*8) content = models.CharField(max_length=1024*8)
likes = models.IntegerField(default=0) likes = models.IntegerField(default=0)
author = models.OneToOneField(CommunityMember, on_delete=models.CASCADE, blank=True, null=True) author = models.OneToOneField(CommunityMember, on_delete=models.CASCADE)
class CommunityPostReports(TimeInfoBase): class CommunityPostReports(TimeInfoBase):
# for anyone who reports a post # for anyone who reports a post
post = models.ForeignKey(CommunityPost, on_delete=models.CASCADE) post = models.ForeignKey(CommunityPost, on_delete=models.CASCADE)
reporter = models.OneToOneField(CommunityMember, on_delete=models.CASCADE, blank=True, null=True) reporter = models.OneToOneField(CommunityMember, on_delete=models.CASCADE)
class CommunityPostLikes(TimeInfoBase): class CommunityPostLikes(TimeInfoBase):
# for anyone who likes a post # for anyone who likes a post
@@ -225,4 +210,4 @@ class CommunityComment(TimeInfoBase):
post = models.ForeignKey(CommunityPost, on_delete=models.CASCADE) post = models.ForeignKey(CommunityPost, on_delete=models.CASCADE)
content = models.CharField(max_length=1024*8) content = models.CharField(max_length=1024*8)
likes = models.IntegerField(default=0) likes = models.IntegerField(default=0)
author = models.OneToOneField(CommunityMember, on_delete=models.CASCADE, blank=True, null=True) author = models.OneToOneField(CommunityMember, on_delete=models.CASCADE)
-19
View File
@@ -10,28 +10,9 @@ section {
scroll-margin-top: 70px; scroll-margin-top: 70px;
} }
/* Skip link visible when focused */
.visually-hidden-focusable:focus {
z-index: 1080;
}
/* Reserve image space to reduce CLS */
.card-img-top {
aspect-ratio: 16 / 9;
object-fit: cover;
width: 100%;
height: auto;
}
img.img-fluid {
max-width: 100%;
height: auto;
}
/* Hero Section */ /* Hero Section */
#home { #home {
background-color: rgba(var(--bs-success-rgb), 0.1); background-color: rgba(var(--bs-success-rgb), 0.1);
min-height: 280px;
} }
/* News and Contact Sections */ /* News and Contact Sections */
@@ -1,29 +0,0 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block content %}
<form action="" method="post">{% csrf_token %}
<p>{{ help_text }}</p>
<fieldset class="module aligned">
<div class="form-row">
<label for="id_year">Year:</label>
<select name="year" id="id_year">
<option value="all">All years</option>
{% for year in years %}
<option value="{{ year }}"{% if year == default_year %} selected{% endif %}>{{ year }}</option>
{% endfor %}
</select>
</div>
</fieldset>
{% for selected_id in selected_ids %}
<input type="hidden" name="_selected_action" value="{{ selected_id }}">
{% endfor %}
<input type="hidden" name="action" value="{{ action_name }}">
<input type="hidden" name="select_across" value="{{ select_across }}">
<input type="hidden" name="index" value="{{ index }}">
<div class="submit-row">
<input type="submit" name="apply" class="default" value="Download CSV">
<a href="{{ changelist_url }}" class="button cancel-link">{% translate "Cancel" %}</a>
</div>
</form>
{% endblock %}
+9 -24
View File
@@ -2,31 +2,16 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>About Us | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Learn about Stonehedge in Wheaton, IL — local schools, community history, shopping and dining nearby, and parks that serve residents.">
{% endblock %}
{% block og_title %}About Us | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Learn about Stonehedge in Wheaton, IL — local schools, community history, shopping and dining nearby, and parks that serve residents.{% endblock %}
{% block twitter_title %}About Us | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Learn about Stonehedge in Wheaton, IL — local schools, community history, shopping and dining nearby, and parks that serve residents.{% endblock %}
{% block content %} {% block content %}
<section class="py-4 bg-success bg-opacity-10">
<div class="container">
<h1 class="text-success mb-0">About Stonehedge</h1>
<p class="lead mb-0 mt-2">Schools, history, nearby amenities, and parks serving our Wheaton community.</p>
</div>
</section>
<section id="schools" class="py-5"> <section id="schools" class="py-5">
<div class="container"> <div class="container">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-lg-6 mb-4 mb-lg-0"> <div class="col-lg-6 mb-4 mb-lg-0">
<img src="{% static 'images/District_200.png' %}" alt="Community Unit School District 200 logo" class="img-fluid rounded shadow" width="600" height="400"> <img src="{% static 'images/District_200.png' %}" alt="Local Schools" class="img-fluid rounded shadow">
</div> </div>
<div class="col-lg-6"> <div class="col-lg-6">
<h2 class="text-success mb-4">Excellent Schools Serving Our Community</h2> <h2 class="text-success mb-4">Excellent Schools Serving Our Community</h2>
@@ -142,7 +127,7 @@
<div class="container"> <div class="container">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-lg-6 order-lg-2 mb-4 mb-lg-0"> <div class="col-lg-6 order-lg-2 mb-4 mb-lg-0">
<img src="{% static 'images/header.gif' %}" alt="Historic view of the Stonehedge community" class="img-fluid rounded shadow" width="600" height="400"> <img src="{% static 'images/header.gif' %}" alt="Community History" class="img-fluid rounded shadow">
</div> </div>
<div class="col-lg-6 order-lg-1"> <div class="col-lg-6 order-lg-1">
<h2 class="text-success mb-4">Our Rich History</h2> <h2 class="text-success mb-4">Our Rich History</h2>
@@ -176,7 +161,7 @@
<div class="row g-4"> <div class="row g-4">
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="{% static 'images/danada.jpeg' %}" class="card-img-top" alt="Danada Square shopping area" width="640" height="360"> <img src="{% static 'images/danada.jpeg' %}" class="card-img-top" alt="Greenwood Mall">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Danada</h5> <h5 class="card-title">Danada</h5>
<p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>1.0 miles from community</p> <p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>1.0 miles from community</p>
@@ -189,7 +174,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="{% static 'images/downtown_wheaton.jpeg' %}" class="card-img-top" alt="Downtown Wheaton streetscape" width="640" height="360"> <img src="{% static 'images/downtown_wheaton.jpeg' %}" class="card-img-top" alt="Farmers Market">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Downtown Wheaton</h5> <h5 class="card-title">Downtown Wheaton</h5>
<p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>2.7 miles from community</p> <p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>2.7 miles from community</p>
@@ -203,7 +188,7 @@
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="{% static 'images/wheaton_farmers_market.jpeg' %}" class="card-img-top" alt="Wheaton Farmers Market stalls" width="640" height="360"> <img src="{% static 'images/wheaton_farmers_market.jpeg' %}" class="card-img-top" alt="Farmers Market">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Wheaton Farmersmarket</h5> <h5 class="card-title">Wheaton Farmersmarket</h5>
<p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>2.8 miles from community</p> <p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>2.8 miles from community</p>
@@ -216,7 +201,7 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="card h-100 shadow-sm"> <div class="card h-100 shadow-sm">
<img src="{% static 'images/downtown_naperville.jpeg' %}" class="card-img-top" alt="Downtown Naperville dining district" width="640" height="360"> <img src="{% static 'images/downtown_naperville.jpeg' %}" class="card-img-top" alt="Dining District">
<div class="card-body"> <div class="card-body">
<h5 class="card-title">Downtown Naperville</h5> <h5 class="card-title">Downtown Naperville</h5>
<p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>6.2 miles from community</p> <p class="card-text"><i class="bi bi-geo-alt-fill text-success me-2"></i>6.2 miles from community</p>
@@ -242,7 +227,7 @@
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="row g-0 h-100"> <div class="row g-0 h-100">
<div class="col-md-5"> <div class="col-md-5">
<img src="{% static 'images/brighton.jpg' %}" class="img-fluid rounded-start h-100" alt="Brighton Park playground and green space" width="400" height="300" style="object-fit: cover;"> <img src="{% static 'images/brighton.jpg' %}" class="img-fluid rounded-start h-100" alt="Community Park" style="object-fit: cover;">
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div class="card-body"> <div class="card-body">
@@ -264,7 +249,7 @@
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="row g-0 h-100"> <div class="row g-0 h-100">
<div class="col-md-5"> <div class="col-md-5">
<img src="{% static 'images/seven_gables.jpg' %}" class="img-fluid rounded-start h-100" alt="Seven Gables Park nature preserve" width="400" height="300" style="object-fit: cover;"> <img src="{% static 'images/seven_gables.jpg' %}" class="img-fluid rounded-start h-100" alt="Nature Preserve" style="object-fit: cover;">
</div> </div>
<div class="col-md-7"> <div class="col-md-7">
<div class="card-body"> <div class="card-body">
+6 -24
View File
@@ -5,22 +5,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
{% block pagetitle %} {% block pagetitle %}
<title>Stonehedge Community Homeowners Association | Wheaton, IL</title>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Stonehedge Community Homeowners Association in Wheaton, Illinois — community news, events, dues, membership, and board information for residents.">
{% endblock %}
<link rel="canonical" href="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}">
<meta property="og:type" content="website">
<meta property="og:site_name" content="Stonehedge Community Homeowners Association">
<meta property="og:locale" content="en_US">
<meta property="og:url" content="{{ request.scheme }}://{{ request.get_host }}{{ request.path }}">
<meta property="og:title" content="{% block og_title %}Stonehedge Community Homeowners Association | Wheaton, IL{% endblock %}">
<meta property="og:description" content="{% block og_description %}Stonehedge Community Homeowners Association in Wheaton, Illinois — community news, events, dues, membership, and board information for residents.{% endblock %}">
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{% block twitter_title %}Stonehedge Community Homeowners Association | Wheaton, IL{% endblock %}">
<meta name="twitter:description" content="{% block twitter_description %}Stonehedge Community Homeowners Association in Wheaton, Illinois — community news, events, dues, membership, and board information for residents.{% endblock %}">
<link rel="alternate" type="text/plain" title="LLM site guidance" href="{% url 'llms_txt' %}">
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons --> <!-- Bootstrap Icons -->
@@ -31,13 +16,12 @@
<script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm9qziuid9vr7v6dtdbnx8406"></script> <script async defer src="https://tianji.aimloperations.com/tracker.js" data-website-id="cm9qziuid9vr7v6dtdbnx8406"></script>
</head> </head>
<body> <body>
<a class="visually-hidden-focusable btn btn-success position-absolute m-2" href="#main-content">Skip to main content</a>
<!-- Navigation --> <!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-success sticky-top" aria-label="Primary"> <nav class="navbar navbar-expand-lg navbar-dark bg-success sticky-top">
<div class="container"> <div class="container">
<a class="navbar-brand" href="{% url 'index2' %}">Stonehedge Community Homeowners Association</a> <a class="navbar-brand" href="{% url 'index2' %}">Stonehedge Community Homeowners Association</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
@@ -68,10 +52,8 @@
</div> </div>
</div> </div>
</nav> </nav>
<main id="main-content">
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</main>
<!-- Footer --> <!-- Footer -->
@@ -79,10 +61,10 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col-lg-6 mb-4 mb-lg-0"> <div class="col-lg-6 mb-4 mb-lg-0">
<h2 class="h5 text-uppercase mb-4">Stonehedge Community Homeowners Association</h2> <h5 class="text-uppercase mb-4">Stonehedge Community Homeowners Association</h5>
</div> </div>
<div class="col-lg-4 offset-lg-2 mb-4 mb-lg-0"> <div class="col-lg-4 offset-lg-2 mb-4 mb-lg-0">
<h2 class="h5 text-uppercase mb-4">Quick Links</h2> <h5 class="text-uppercase mb-4">Quick Links</h5>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li class="mb-2"><a href="{% url 'index2' %}" class="text-white-50 text-decoration-none">Home</a></li> <li class="mb-2"><a href="{% url 'index2' %}" class="text-white-50 text-decoration-none">Home</a></li>
<li class="mb-2"><a href="{% url 'about_us2' %}" class="text-white-50 text-decoration-none">About</a></li> <li class="mb-2"><a href="{% url 'about_us2' %}" class="text-white-50 text-decoration-none">About</a></li>
@@ -97,8 +79,8 @@
<hr class="my-4 text-white-50"> <hr class="my-4 text-white-50">
<div class="row align-items-center"> <div class="row align-items-center">
<div id="footer"> <div id="footer">
<p>&copy; Stonehedge Community Homeowners Association 2010-{% now "Y" %}. All rights reserved.</p> <p>&copy; Stonehedge Community Homeowners Association 2010-<script>document.write( new Date().getFullYear() );</script>. All rights reserved </p>
<p> Developed by <a href="https://aimloperations.com" class="text-white-50">AI ML Operations, LLC</a></p> <p> Developed by <a href="https://aimloperations.com">AI ML Operations, LLC</a></p>
</div> </div>
</div> </div>
</footer> </footer>
@@ -23,9 +23,6 @@
{% else %} {% else %}
<p>Date: {{ event.start_date }} - {{ event.end_date }}</p> <p>Date: {{ event.start_date }} - {{ event.end_date }}</p>
{% endif %} {% endif %}
{% if event.time_range_display %}
<p>Time: {{ event.time_range_display }}</p>
{% endif %}
<p>Location: {{ event.location_name }} - {{ event.calendareventaddressmodel }}</p> <p>Location: {{ event.location_name }} - {{ event.calendareventaddressmodel }}</p>
{% if event.coordinator_email %} {% if event.coordinator_email %}
<p>Coordinator: {{ event.coordinator_email }}</p> <p>Coordinator: {{ event.coordinator_email }}</p>
@@ -47,9 +44,6 @@
{% else %} {% else %}
<p>Date: {{ event.start_date }} - {{ event.end_date }}</p> <p>Date: {{ event.start_date }} - {{ event.end_date }}</p>
{% endif %} {% endif %}
{% if event.time_range_display %}
<p>Time: {{ event.time_range_display }}</p>
{% endif %}
<p>Location: {{ event.location_name }} - {{ event.calendareventaddressmodel }}</p> <p>Location: {{ event.location_name }} - {{ event.calendareventaddressmodel }}</p>
{% if event.coordinator_email %} {% if event.coordinator_email %}
<p>Coordinator: {{ event.coordinator_email }}</p> <p>Coordinator: {{ event.coordinator_email }}</p>
+7 -21
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Calendar | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <style type="text/css">
/* Calendar Page Specific Styles */ /* Calendar Page Specific Styles */
.event-card { .event-card {
@@ -23,7 +23,6 @@
height: 180px; height: 180px;
width: 100%; width: 100%;
object-fit: cover; object-fit: cover;
aspect-ratio: 16 / 9;
} }
.nav-tabs .nav-link { .nav-tabs .nav-link {
@@ -63,29 +62,20 @@
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Stonehedge community calendar — upcoming HOA events, gatherings, and past activities for Wheaton residents.">
{% endblock %}
{% block og_title %}Calendar | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Stonehedge community calendar — upcoming HOA events, gatherings, and past activities for Wheaton residents.{% endblock %}
{% block twitter_title %}Calendar | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Stonehedge community calendar — upcoming HOA events, gatherings, and past activities for Wheaton residents.{% endblock %}
{% block content %} {% block content %}
<div class="py-5"> <main class="py-5">
<div class="container"> <div class="container">
<!-- Calendar Navigation --> <!-- Calendar Navigation -->
<div class="row mb-4"> <div class="row mb-4">
<div class="col-12"> <div class="col-12">
<h1 class="text-success mb-3">Community Calendar</h1>
<ul class="nav nav-tabs" id="calendarTabs" role="tablist"> <ul class="nav nav-tabs" id="calendarTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="upcoming-tab" data-bs-toggle="tab" data-bs-target="#upcoming" type="button" role="tab" aria-controls="upcoming" aria-selected="true"> <button class="nav-link active" id="upcoming-tab" data-bs-toggle="tab" data-bs-target="#upcoming" type="button" role="tab">
Upcoming Events Upcoming Events
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="past-tab" data-bs-toggle="tab" data-bs-target="#past" type="button" role="tab" aria-controls="past" aria-selected="false"> <button class="nav-link" id="past-tab" data-bs-toggle="tab" data-bs-target="#past" type="button" role="tab">
Past Events Past Events
</button> </button>
</li> </li>
@@ -115,9 +105,7 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="mb-3"> <div class="mb-3">
<p class="mb-1"><i class="bi bi-calendar-event text-success me-2"></i><strong>Date:</strong> {{ event.start_date }}</p> <p class="mb-1"><i class="bi bi-calendar-event text-success me-2"></i><strong>Date:</strong> {{ event.start_date }}</p>
{% if event.time_range_display %} <p class="mb-1"><i class="bi bi-clock text-success me-2"></i><strong>Time:</strong> 11:00 AM - 3:00 PM</p>
<p class="mb-1"><i class="bi bi-clock text-success me-2"></i><strong>Time:</strong> {{ event.time_range_display }}</p>
{% endif %}
<p class="mb-1"><i class="bi bi-geo-alt text-success me-2"></i><strong>Location:</strong> {{ event.location_name }}</p> <p class="mb-1"><i class="bi bi-geo-alt text-success me-2"></i><strong>Location:</strong> {{ event.location_name }}</p>
<p class="mb-1"><i class="bi bi-house text-success me-2"></i><strong>Address:</strong> </p> <p class="mb-1"><i class="bi bi-house text-success me-2"></i><strong>Address:</strong> </p>
<p class="mb-1"><i class="bi bi-person text-success me-2"></i><strong>Coordinator:</strong> {{ event.coordinator_name }}</p> <p class="mb-1"><i class="bi bi-person text-success me-2"></i><strong>Coordinator:</strong> {{ event.coordinator_name }}</p>
@@ -161,9 +149,7 @@
<div class="col-md-8"> <div class="col-md-8">
<div class="mb-3"> <div class="mb-3">
<p class="mb-1"><i class="bi bi-calendar-event text-secondary me-2"></i><strong>Date:</strong> {{ event.start_date }}</p> <p class="mb-1"><i class="bi bi-calendar-event text-secondary me-2"></i><strong>Date:</strong> {{ event.start_date }}</p>
{% if event.time_range_display %} <p class="mb-1"><i class="bi bi-clock text-secondary me-2"></i><strong>Time:</strong> 9:00 AM - 12:00 PM</p>
<p class="mb-1"><i class="bi bi-clock text-secondary me-2"></i><strong>Time:</strong> {{ event.time_range_display }}</p>
{% endif %}
<p class="mb-1"><i class="bi bi-geo-alt text-secondary me-2"></i><strong>Location:</strong> {{ event.location_name }}</p> <p class="mb-1"><i class="bi bi-geo-alt text-secondary me-2"></i><strong>Location:</strong> {{ event.location_name }}</p>
<p class="mb-1"><i class="bi bi-house text-secondary me-2"></i><strong>Address:</strong> </p> <p class="mb-1"><i class="bi bi-house text-secondary me-2"></i><strong>Address:</strong> </p>
<p class="mb-1"><i class="bi bi-person text-secondary me-2"></i><strong>Coordinator:</strong> {{ event.coordinator_name }}</p> <p class="mb-1"><i class="bi bi-person text-secondary me-2"></i><strong>Coordinator:</strong> {{ event.coordinator_name }}</p>
@@ -186,7 +172,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
+5 -13
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Pay Dues | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <style type="text/css">
/* Payment Page Specific Styles */ /* Payment Page Specific Styles */
.payment-option-card { .payment-option-card {
@@ -50,23 +50,15 @@
</style> </style>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Pay annual Stonehedge HOA dues securely online via Stripe. Current annual dues are $30.">
{% endblock %}
{% block og_title %}Pay Dues | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Pay annual Stonehedge HOA dues securely online via Stripe. Current annual dues are $30.{% endblock %}
{% block twitter_title %}Pay Dues | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Pay annual Stonehedge HOA dues securely online via Stripe. Current annual dues are $30.{% endblock %}
{% block content %} {% block content %}
<div class="py-5"> <main class="py-5">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
<!-- Payment Information Card --> <!-- Payment Information Card -->
<div class="card shadow-sm mb-5"> <div class="card shadow-sm mb-5">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<h1 class="h3 mb-0">Payment Information</h1> <h3 class="mb-0">Payment Information</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="alert alert-info"> <div class="alert alert-info">
@@ -118,7 +110,7 @@
<h4 class="text-success">Credit/Debit Card</h4> <h4 class="text-success">Credit/Debit Card</h4>
<p class="card-text">Pay securely with Visa, Mastercard, American Express, or Discover.</p> <p class="card-text">Pay securely with Visa, Mastercard, American Express, or Discover.</p>
<button class="btn btn-success mt-3" id="submitBtn" type="button" aria-label="Pay annual dues with credit or debit card">Pay Dues</button> <button class="btn btn-success mt-3" id="submitBtn">Pay Dues</button>
</div> </div>
</div> </div>
</div> </div>
@@ -192,7 +184,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
{% block extra_js %} {% block extra_js %}
@@ -2,18 +2,16 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Payment Cancelled | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
{% endblock %}
{% block meta_tags %}
<meta name="description" content="Your Stonehedge HOA dues payment was cancelled.">
<meta name="robots" content="noindex, nofollow">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="py-5">
<div class="container">
<h1>Your payment cancelled.</h1>
</div> <div class="mid-body">
</div> <h1>Your payment cancelled.</h1>
</div>
{% endblock %} {% endblock %}
+9 -11
View File
@@ -2,18 +2,16 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Payment Successful | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
{% endblock %}
{% block meta_tags %}
<meta name="description" content="Your Stonehedge HOA dues payment was successful.">
<meta name="robots" content="noindex, nofollow">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="py-5">
<div class="container">
<h1>Your payment succeeded.</h1>
</div> <div class="mid-body">
</div> <h1>Your payment succeeded.</h1>
</div>
{% endblock %} {% endblock %}
+1 -9
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Home | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <style type="text/css">
.hero-section { .hero-section {
background: url({% static 'images/header.gif' %}); background: url({% static 'images/header.gif' %});
@@ -20,14 +20,6 @@
</style> </style>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Welcome to Stonehedge Community Homeowners Association in Wheaton, IL. Pay dues, join as a member, find events, and meet the HOA board.">
{% endblock %}
{% block og_title %}Home | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Welcome to Stonehedge Community Homeowners Association in Wheaton, IL. Pay dues, join as a member, find events, and meet the HOA board.{% endblock %}
{% block twitter_title %}Home | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Welcome to Stonehedge Community Homeowners Association in Wheaton, IL. Pay dues, join as a member, find events, and meet the HOA board.{% endblock %}
{% block content %} {% block content %}
<!-- Hero Section --> <!-- Hero Section -->
<section id="home" class="py-5 bg-success bg-opacity-10"> <section id="home" class="py-5 bg-success bg-opacity-10">
-32
View File
@@ -1,32 +0,0 @@
# Stonehedge Community Homeowners Association (SCHA)
> Public website for the Stonehedge Community Homeowners Association in Wheaton, Illinois.
Stonehedge is a residential homeowners association serving Wheaton, IL. This site helps residents learn about the community, pay annual dues, join as members, view events, meet the board, and find useful local links.
## Primary pages
- [Home]({{ site_root }}): Community overview and quick links to dues, membership, events, and the board
- [About Us]({{ site_root }}about_us): Schools, community history, local shopping/dining, and nearby parks
- [Calendar]({{ site_root }}calendar): Upcoming and past community events
- [Pay Dues]({{ site_root }}dues): Annual dues payment via Stripe checkout ($30/year)
- [Join Today]({{ site_root }}membership_form): New member registration form (address, household contacts, committee interests)
- [SCHA Board]({{ site_root }}scha_board): Current volunteer board officers and contacts
- [Useful Links]({{ site_root }}useful_links): External resources for residents
## Machine-readable discovery
- Sitemap: {{ site_root }}sitemap.xml
- Robots: {{ site_root }}robots.txt
- This file: {{ site_root }}llms.txt
## Transactional flows
- Membership: HTML form POST to /membership_form (CSRF protected; includes reCAPTCHA)
- Dues payment: client creates Stripe Checkout session via /create-checkout-session/, then redirects to Stripe
## Notes for agents
- Prefer public navigation links in the primary navbar / footer
- Do not scrape or interact with /admin/ or member-only DEBUG routes
- Keep interactions within visible, labeled controls; payment completes on Stripe's hosted checkout
@@ -2,8 +2,8 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Join Today | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <script type="text/css">
/* Membership Form Specific Styles */ /* Membership Form Specific Styles */
#membershipForm fieldset { #membershipForm fieldset {
margin-bottom: 2rem; margin-bottom: 2rem;
@@ -60,26 +60,18 @@
.modal-header .btn-close { .modal-header .btn-close {
filter: invert(1); filter: invert(1);
} }
</style> </script>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Join Stonehedge Community Homeowners Association. Submit membership details, household contacts, and committee interests.">
{% endblock %}
{% block og_title %}Join Today | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Join Stonehedge Community Homeowners Association. Submit membership details, household contacts, and committee interests.{% endblock %}
{% block twitter_title %}Join Today | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Join Stonehedge Community Homeowners Association. Submit membership details, household contacts, and committee interests.{% endblock %}
{% block content %} {% block content %}
<div class="py-5"> <main class="py-5">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<h1 class="h4 mb-0">New Member Information</h1> <h2 class="h4 mb-0">New Member Information</h2>
</div> </div>
<div class="card-body"> <div class="card-body">
<form id="membershipForm" method="POST" action='{% url "membership_form2" %}' class="needs-validation" novalidate> <form id="membershipForm" method="POST" action='{% url "membership_form2" %}' class="needs-validation" novalidate>
@@ -120,7 +112,7 @@
</div> </div>
<div class="col-md-4"> <div class="col-md-4">
<label for="zipCode" class="form-label">ZIP Code*</label> <label for="zipCode" class="form-label">ZIP Code*</label>
<input type="text" class="form-control" id="zipCode" name="zipCode" value="60189" required> <input type="text" class="form-control" id="zipCode" value="zipCode" value="60189" required>
<div class="invalid-feedback"> <div class="invalid-feedback">
Please provide your ZIP code. Please provide your ZIP code.
</div> </div>
@@ -205,77 +197,77 @@
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="block_captain" name="block_captain"> <input class="form-check-input" type="checkbox" id="block_captain" for="block_captain" name="block_captain">
<label class="form-check-label" for="block_captain"> <label class="form-check-label" id="block_captain">
BlockCaptain BlockCaptain
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="coordinator" name="coordinator"> <input class="form-check-input" type="checkbox" id="coordinator" for="coordinator" name="coordinator">
<label class="form-check-label" for="coordinator"> <label class="form-check-label" id="coordinator">
Coordinator Coordinator
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="egg_hunt" name="egg_hunt"> <input class="form-check-input" type="checkbox" id="egg_hunt" for="egg_hunt" name="egg_hunt">
<label class="form-check-label" for="egg_hunt"> <label class="form-check-label" id="egg_hunt">
Easter Egg Hunt Easter Egg Hunt
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="spring_garage_sale" name="spring_garage_sale"> <input class="form-check-input" type="checkbox" id="spring_garage_sale" for="spring_garage_sale" name="spring_garage_sale">
<label class="form-check-label" for="spring_garage_sale"> <label class="form-check-label" id="spring_garage_sale">
Spring Garage Sale Spring Garage Sale
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="golf_outing" name="golf_outing"> <input class="form-check-input" type="checkbox" id="golf_outing" for="golf_outing" name="golf_outing">
<label class="form-check-label" for="golf_outing"> <label class="form-check-label" id="golf_outing">
Golf Outing Golf Outing
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="ice_cream_social" name="ice_cream_social"> <input class="form-check-input" type="checkbox" id="ice_cream_social" for="ice_cream_social" name="ice_cream_social">
<label class="form-check-label" for="ice_cream_social"> <label class="form-check-label" id="ice_cream_social">
Ice Creame Social Ice Creame Social
</label> </label>
</div> </div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="fall_garage_sale" name="fall_garage_sale"> <input class="form-check-input" type="checkbox" id="fall_garage_sale" for="fall_garage_sale" name="fall_garage_sale">
<label class="form-check-label" for="fall_garage_sale"> <label class="form-check-label" id="fall_garage_sale">
Fall Garage Sale Fall Garage Sale
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="halloween_party" name="halloween_party"> <input class="form-check-input" type="checkbox" id="halloween_party" for="halloween_party" name="halloween_party">
<label class="form-check-label" for="halloween_party"> <label class="form-check-label" id="halloween_party">
Halloween Party Halloween Party
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="santa_visit" name="santa_visit"> <input class="form-check-input" type="checkbox" id="santa_visit" for="santa_visit" name="santa_visit">
<label class="form-check-label" for="santa_visit"> <label class="form-check-label" id="santa_visit">
Santa Visits Santa Visits
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="civic_affair" name="civic_affair"> <input class="form-check-input" type="checkbox" id="civic_affair" for="civic_affair" name="civic_affair">
<label class="form-check-label" for="civic_affair"> <label class="form-check-label" id="civic_affair">
Civic Affairs Journalist Civic Affairs Journalist
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="phone_directory" name="phone_directory"> <input class="form-check-input" type="checkbox" id="phone_directory" for="phone_directory" name="phone_directory">
<label class="form-check-label" for="phone_directory"> <label class="form-check-label" id="phone_directory">
Annual Phone Director Annual Phone Director
</label> </label>
</div> </div>
<div class="form-check mb-2"> <div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="no_preference" name="no_preference"> <input class="form-check-input" type="checkbox" id="no_preference" for="no_preference" name="no_preference">
<label class="form-check-label" for="no_preference"> <label class="form-check-label" id="no_preference">
No Preference No Preference
</label> </label>
</div> </div>
@@ -360,7 +352,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
+1 -15
View File
@@ -2,22 +2,8 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Newsletters | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Stonehedge Community Homeowners Association newsletters and community updates for Wheaton residents.">
{% endblock %}
{% block og_title %}Newsletters | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Stonehedge Community Homeowners Association newsletters and community updates for Wheaton residents.{% endblock %}
{% block twitter_title %}Newsletters | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Stonehedge Community Homeowners Association newsletters and community updates for Wheaton residents.{% endblock %}
{% block content %} {% block content %}
<div class="py-5">
<div class="container">
<h1 class="text-success mb-3">Newsletters</h1>
<p class="lead">Community newsletters will appear here as they are published.</p>
</div>
</div>
{% endblock %} {% endblock %}
-18
View File
@@ -1,18 +0,0 @@
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /login/
Disallow: /signup/
Disallow: /dashboard/
Disallow: /directory/
Disallow: /posts/
Disallow: /posts_create/
Disallow: /profile/
Disallow: /password_reset/
Disallow: /set_password/
Disallow: /config/
Disallow: /create-checkout-session/
Disallow: /webhook/
Sitemap: {{ sitemap_url }}
+4 -12
View File
@@ -2,7 +2,7 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>SCHA Board | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <style type="text/css">
/* Board Page Specific Styles */ /* Board Page Specific Styles */
.card-header h4 { .card-header h4 {
@@ -35,23 +35,15 @@
</style> </style>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Meet the volunteer Stonehedge Community Homeowners Association board officers serving Wheaton residents.">
{% endblock %}
{% block og_title %}SCHA Board | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Meet the volunteer Stonehedge Community Homeowners Association board officers serving Wheaton residents.{% endblock %}
{% block twitter_title %}SCHA Board | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Meet the volunteer Stonehedge Community Homeowners Association board officers serving Wheaton residents.{% endblock %}
{% block content %} {% block content %}
<div class="py-5"> <main class="py-5">
<div class="container"> <div class="container">
<!-- Board Information --> <!-- Board Information -->
<div class="row justify-content-center mb-5"> <div class="row justify-content-center mb-5">
<div class="col-lg-10"> <div class="col-lg-10">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body text-center"> <div class="card-body text-center">
<h1 class="h3 text-success mb-3">2025-2026 Stonehedge Community Homeowners Association Board</h1> <h3 class="text-success mb-3">2025-2026 Stonehedge Community Homeowners Association Board</h3>
<p>The Stonehedge Community Homeowners Association is governed by a volunteer board of directors elected by the community members. Board members serve two-year terms and are responsible for overseeing the community's operations, finances, and enforcement of covenants.</p> <p>The Stonehedge Community Homeowners Association is governed by a volunteer board of directors elected by the community members. Board members serve two-year terms and are responsible for overseeing the community's operations, finances, and enforcement of covenants.</p>
</div> </div>
</div> </div>
@@ -106,5 +98,5 @@
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
-10
View File
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for entry in urls %}
<url>
<loc>{{ entry.loc }}</loc>
<changefreq>{{ entry.changefreq }}</changefreq>
<priority>{{ entry.priority }}</priority>
</url>
{% endfor %}
</urlset>
+6 -14
View File
@@ -2,8 +2,8 @@
{% load static %} {% load static %}
{% block pagetitle %} {% block pagetitle %}
<title>Useful Links | Stonehedge Community Homeowners Association</title> <title>Stonehedge Community Homeowners Association</title>
<style type="text/css"> <script type="text/css">
/* Links Page Specific Styles */ /* Links Page Specific Styles */
.links-table th { .links-table th {
background-color: var(--bs-success); background-color: var(--bs-success);
@@ -41,25 +41,17 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
} }
</style> </script>
{% endblock %} {% endblock %}
{% block meta_tags %}
<meta name="description" content="Helpful links for Stonehedge residents — city services, utilities, schools, and neighborhood resources.">
{% endblock %}
{% block og_title %}Useful Links | Stonehedge Community Homeowners Association{% endblock %}
{% block og_description %}Helpful links for Stonehedge residents — city services, utilities, schools, and neighborhood resources.{% endblock %}
{% block twitter_title %}Useful Links | Stonehedge Community Homeowners Association{% endblock %}
{% block twitter_description %}Helpful links for Stonehedge residents — city services, utilities, schools, and neighborhood resources.{% endblock %}
{% block content %} {% block content %}
<div class="py-5"> <main class="py-5">
<div class="container"> <div class="container">
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-10"> <div class="col-lg-10">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-success text-white"> <div class="card-header bg-success text-white">
<h1 class="h3 mb-0">Useful Links</h1> <h3 class="mb-0">Useful Links</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="table-responsive"> <div class="table-responsive">
@@ -272,7 +264,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </main>
{% endblock %} {% endblock %}
-245
View File
@@ -6,17 +6,12 @@ from django.urls import reverse
from scha.settings.base import build_csrf_trusted_origins from scha.settings.base import build_csrf_trusted_origins
from django.contrib.auth.models import User
from django.utils import timezone
from .admin import membership_payment_exists, person_payment_exists
from .forms import AddressForm, PeopleForm from .forms import AddressForm, PeopleForm
from .models import ( from .models import (
AddressModel1, AddressModel1,
CalendarEvent, CalendarEvent,
Membership, Membership,
MembershipPerson, MembershipPerson,
Payments,
SCHAOfficer, SCHAOfficer,
UsefulLinks, UsefulLinks,
) )
@@ -94,49 +89,6 @@ class PublicPageTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "City of Wheaton") self.assertContains(response, "City of Wheaton")
def test_public_pages_include_seo_metadata(self):
pages = [
("index2", "Home | Stonehedge"),
("about_us2", "About Us | Stonehedge"),
("calendar2", "Calendar | Stonehedge"),
("dues2", "Pay Dues | Stonehedge"),
("membership_form2", "Join Today | Stonehedge"),
("scha_board2", "SCHA Board | Stonehedge"),
("useful_links2", "Useful Links | Stonehedge"),
]
for name, title_fragment in pages:
with self.subTest(page=name):
response = self.client.get(reverse(name))
self.assertEqual(response.status_code, 200)
self.assertContains(response, f"<title>{title_fragment}")
self.assertContains(response, 'name="description"')
self.assertContains(response, 'property="og:title"')
self.assertContains(response, 'rel="canonical"')
self.assertContains(response, 'id="main-content"')
self.assertContains(response, "Skip to main content")
def test_robots_txt(self):
response = self.client.get(reverse("robots_txt"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"].split(";")[0], "text/plain")
self.assertContains(response, "Sitemap:")
self.assertContains(response, "Disallow: /admin/")
def test_sitemap_xml(self):
response = self.client.get(reverse("sitemap_xml"))
self.assertEqual(response.status_code, 200)
self.assertIn("xml", response["Content-Type"])
self.assertContains(response, reverse("index2"))
self.assertContains(response, reverse("dues2"))
def test_llms_txt(self):
response = self.client.get(reverse("llms_txt"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"].split(";")[0], "text/plain")
self.assertContains(response, "Stonehedge Community Homeowners Association")
self.assertContains(response, reverse("membership_form2").lstrip("/"))
@override_settings( @override_settings(
RECAPTCHA_PUBLIC_KEY="test-public-key", RECAPTCHA_PUBLIC_KEY="test-public-key",
@@ -326,23 +278,6 @@ class CalendarEventTests(TestCase):
self.assertFalse(undated.past_event()) self.assertFalse(undated.past_event())
self.assertFalse(undated.future_event()) self.assertFalse(undated.future_event())
def test_time_range_display(self):
event = CalendarEvent.objects.create(
event_name="Timed Event",
start_time=datetime.time(11, 0),
end_time=datetime.time(15, 0),
)
self.assertEqual(event.time_range_display(), "11:00 AM - 3:00 PM")
start_only = CalendarEvent.objects.create(
event_name="Start Only",
start_time=datetime.time(9, 30),
)
self.assertEqual(start_only.time_range_display(), "9:30 AM")
no_time = CalendarEvent.objects.create(event_name="No Time")
self.assertEqual(no_time.time_range_display(), "")
def test_calendar_page_splits_past_and_future(self): def test_calendar_page_splits_past_and_future(self):
today = datetime.date.today() today = datetime.date.today()
CalendarEvent.objects.create( CalendarEvent.objects.create(
@@ -474,183 +409,3 @@ class AuthViewTests(TestCase):
response = self.views.member_posts(request) response = self.views.member_posts(request)
self.assertEqual(response.status_code, 302) self.assertEqual(response.status_code, 302)
self.assertIn("/login", response.url) self.assertIn("/login", response.url)
def _membership_with_person(email="alex@example.com"):
membership = Membership.objects.create()
AddressModel1.objects.create(
membership=membership,
address_1="123 Main St",
city="Wheaton",
state="IL",
zip_code="60189",
)
person = MembershipPerson.objects.create(
membership=membership,
first_name="Alex",
last_name="Resident",
email=email,
phone_number="+16305559876",
)
return membership, person
class AdminPaidIndicatorTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
self.this_year = timezone.now().year
self.membership, self.person = _membership_with_person("paid@example.com")
def test_person_marked_paid_when_email_matches_current_year_payment(self):
Payments.objects.create(
email="PAID@example.com",
date=datetime.date(self.this_year, 3, 1),
)
paid = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=self.person.pk)
self.assertTrue(paid._has_payment)
unpaid_membership, unpaid_person = _membership_with_person("unpaid@example.com")
unpaid = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=unpaid_person.pk)
self.assertFalse(unpaid._has_payment)
membership = Membership.objects.annotate(
_has_payment=membership_payment_exists()
).get(pk=self.membership.pk)
self.assertTrue(membership._has_payment)
other = Membership.objects.annotate(
_has_payment=membership_payment_exists()
).get(pk=unpaid_membership.pk)
self.assertFalse(other._has_payment)
def test_prior_year_payment_does_not_count_as_paid(self):
Payments.objects.create(
email="paid@example.com",
date=datetime.date(self.this_year - 1, 6, 1),
)
person = MembershipPerson.objects.annotate(
_has_payment=person_payment_exists()
).get(pk=self.person.pk)
self.assertFalse(person._has_payment)
def test_person_changelist_shows_paid_icon(self):
Payments.objects.create(
email="paid@example.com",
date=datetime.date(self.this_year, 4, 15),
)
url = reverse("admin:schasite_membershipperson_changelist")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Paid this year")
self.assertContains(response, "icon-yes.svg")
class AdminPaymentSearchTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
Payments.objects.create(email="dues@example.com", date=datetime.date(2026, 1, 10))
Payments.objects.create(email="other@example.com", date=datetime.date(2026, 2, 10))
def test_payments_admin_search_by_email(self):
url = reverse("admin:schasite_payments_changelist")
response = self.client.get(url, {"q": "dues@example.com"})
self.assertEqual(response.status_code, 200)
self.assertContains(response, "dues@example.com")
self.assertNotContains(response, "other@example.com")
class AdminCsvYearFilterTests(TestCase):
def setUp(self):
self.client = Client()
self.admin_user = User.objects.create_superuser(
"admin", "admin@example.com", "pass"
)
self.client.force_login(self.admin_user)
self.current, self.current_person = _membership_with_person("now@example.com")
self.old, self.old_person = _membership_with_person("old@example.com")
Membership.objects.filter(pk=self.old.pk).update(
created=timezone.make_aware(datetime.datetime(2024, 5, 1, 12, 0, 0))
)
Payments.objects.create(email="now@example.com", date=datetime.date(2026, 3, 1))
Payments.objects.create(email="old@example.com", date=datetime.date(2024, 3, 1))
def test_membership_csv_prompts_for_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_members",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
},
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Year:")
self.assertContains(response, "Download CSV")
def test_membership_csv_filters_by_created_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_members",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/csv")
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)
def test_membership_csv_by_address_filters_by_created_year(self):
url = reverse("admin:schasite_membership_changelist")
response = self.client.post(
url,
{
"action": "download_csv_by_address",
"_selected_action": [str(self.current.pk), str(self.old.pk)],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)
def test_payments_csv_filters_by_payment_year(self):
url = reverse("admin:schasite_payments_changelist")
payments = list(Payments.objects.values_list("pk", flat=True))
response = self.client.post(
url,
{
"action": "download_payments",
"_selected_action": [str(pk) for pk in payments],
"index": "0",
"apply": "Download CSV",
"year": "2024",
},
)
self.assertEqual(response.status_code, 200)
content = response.content.decode()
self.assertIn("old@example.com", content)
self.assertNotIn("now@example.com", content)
-3
View File
@@ -38,9 +38,6 @@ urlpatterns = authenticated_views + [
path("membership_form", views.membership_form2, name="membership_form2"), path("membership_form", views.membership_form2, name="membership_form2"),
path("scha_board", views.scha_board2, name="scha_board2"), path("scha_board", views.scha_board2, name="scha_board2"),
path("useful_links", views.useful_links2, name="useful_links2"), path("useful_links", views.useful_links2, name="useful_links2"),
path("robots.txt", views.robots_txt, name="robots_txt"),
path("sitemap.xml", views.sitemap_xml, name="sitemap_xml"),
path("llms.txt", views.llms_txt, name="llms_txt"),
# stripe specific urls below # stripe specific urls below
path("config/", views.stripe_config), path("config/", views.stripe_config),
path("create-checkout-session/", views.create_checkout_session), path("create-checkout-session/", views.create_checkout_session),
-50
View File
@@ -18,7 +18,6 @@ from django.http.response import JsonResponse, HttpResponse
from django.views.decorators.csrf import csrf_exempt # new from django.views.decorators.csrf import csrf_exempt # new
from django.contrib.auth import logout, authenticate, login from django.contrib.auth import logout, authenticate, login
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.urls import reverse
import stripe import stripe
import logging import logging
@@ -123,55 +122,6 @@ def useful_links(request):
return render(request, "schasite/useful_links.html", {"links": useful_links}) return render(request, "schasite/useful_links.html", {"links": useful_links})
PUBLIC_SITEMAP_ROUTES = (
("index2", "weekly", "1.0"),
("about_us2", "monthly", "0.8"),
("calendar2", "weekly", "0.8"),
("dues2", "monthly", "0.9"),
("membership_form2", "monthly", "0.9"),
("scha_board2", "monthly", "0.7"),
("useful_links2", "monthly", "0.6"),
("newsletters2", "monthly", "0.5"),
)
def robots_txt(request):
sitemap_url = request.build_absolute_uri(reverse("sitemap_xml"))
return render(
request,
"schasite/robots.txt",
{"sitemap_url": sitemap_url},
content_type="text/plain",
)
def sitemap_xml(request):
urls = [
{
"loc": request.build_absolute_uri(reverse(name)),
"changefreq": changefreq,
"priority": priority,
}
for name, changefreq, priority in PUBLIC_SITEMAP_ROUTES
]
return render(
request,
"schasite/sitemap.xml",
{"urls": urls},
content_type="application/xml",
)
def llms_txt(request):
site_root = request.build_absolute_uri("/")
return render(
request,
"schasite/llms.txt",
{"site_root": site_root},
content_type="text/plain; charset=utf-8",
)
def index2(request): def index2(request):
return render(request, "schasite/index2.html", {}) return render(request, "schasite/index2.html", {})
Generated
+4 -4
View File
@@ -101,16 +101,16 @@ wheels = [
[[package]] [[package]]
name = "django" name = "django"
version = "6.0.7" version = "5.2.16"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "asgiref" }, { name = "asgiref" },
{ name = "sqlparse" }, { name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" }, { name = "tzdata", marker = "sys_platform == 'win32'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/89/55/664f24ff81c9ea19cb7dfc851afeae1f3c2390c7aee01d4ded68b5c1580d/django-6.0.7.tar.gz", hash = "sha256:2998503fc083124fb58037084bfa00de323c7c743f05f1b4284e77bff0ab8890", size = 10921299, upload-time = "2026-07-07T13:51:26.485Z" } sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/ec/1ce5334b6a2c52ce619c23a0be8d366a57a0e080ebb2d88266e5c849157c/django-6.0.7-py3-none-any.whl", hash = "sha256:a037427c2288443a8c02a1b02295a31c239663aa682bc50b1976afb7cf6a769e", size = 8373344, upload-time = "2026-07-07T13:51:20.007Z" }, { url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
] ]
[[package]] [[package]]
@@ -366,7 +366,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "django", specifier = ">=6.0,<7" }, { name = "django", specifier = ">=5.2,<6" },
{ name = "django-phonenumber-field", specifier = ">=8.1.0" }, { name = "django-phonenumber-field", specifier = ">=8.1.0" },
{ name = "django-recaptcha", specifier = ">=4.1.0" }, { name = "django-recaptcha", specifier = ">=4.1.0" },
{ name = "gunicorn", specifier = ">=23.0.0" }, { name = "gunicorn", specifier = ">=23.0.0" },