Architecture
Generated: 2026-04-06 | Scan level: exhaustive
Executive Summary
Desparchado is a Django monolith that aggregates cultural and educational events in Colombian cities. It serves both server-rendered HTML pages (Django templates) and a minimal REST API consumed by co-located Vue.js 3 components. The application is deployed as a single Docker container (Gunicorn + Nginx) backed by PostgreSQL with the PostGIS extension for geographic data.
Architecture Type
Monolith with Embedded SPA Islands
Django is responsible for all routing, authentication, permissions, business logic, and server-side rendering. Vue.js 3 components are mounted as isolated interactive islands on specific pages — they do not take over the entire page. Navigation between pages is handled by Django URL patterns and standard HTML links, not client-side routing.
Application Layers
┌─────────────────────────────────────────────────────────┐
│ Browser / Client │
│ Django-rendered HTML + Vue 3 island components │
└──────────────┬──────────────┬──────────────────────────-┘
│ HTML pages │ XHR (fetch)
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Django Application (Gunicorn) │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Web Views │ │ REST API │ │ Dashboard │ │
│ │ (CBVs) │ │ (DRF) │ │ (superuser) │ │
│ └──────┬──────┘ └──────┬───────┘ └───────┬───────┘ │
│ │ │ │ │
│ ┌──────▼────────────────▼──────────────────▼───────┐ │
│ │ Services / QuerySets │ │
│ │ event_search.py | spreadsheet_sync.py | filbo │ │
│ └─────────────────────────┬─────────────────────── ┘ │
│ │ │
│ ┌──────────────────────────▼─────────────────────────┐ │
│ │ ORM / Models │ │
│ │ Event | Place | City | Organizer | Speaker | ... │ │
│ └──────────────────────────┬─────────────────────── ┘ │
└─────────────────────────────┼───────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ PostgreSQL + PostGIS (desparchado_dev) │
└─────────────────────────────────────────────────────────┘
Django App Structure
| App | Responsibility | Key models |
|---|---|---|
desparchado |
Root config, shared utils, homepage | — |
events |
Core event domain; CRUD, search, API | Event, Organizer, Speaker, SocialNetworkPost |
places |
Venue and city management | Place, City |
specials |
Named event collections | Special |
dashboard |
Superuser tools; spreadsheet imports | SpreadsheetSync |
users |
User profiles and quotas | UserSettings, UserEventRelation |
blog |
Blog articles | Post |
games |
"La caza del Snark" game | HuntingOfSnarkGame |
history |
Colombian cultural history timeline | HistoricalFigure, Event, Post, Group |
news |
Placeholder (no views yet) | — |
books |
Placeholder (empty) | — |
URL Namespace Map
| Prefix | Namespace | App |
|---|---|---|
/ |
— | Homepage (desparchado/views/home.py) |
/events/ |
events |
Events, Organizers, Speakers CRUD |
/events/api/v1/ |
events_api |
REST API |
/places/ |
places |
Places, Cities |
/specials/ |
specials |
Special collections |
/blog/ |
blog |
Blog posts |
/games/ |
games |
Hunting of Snark game |
/historia/ |
history |
Historical figures and timeline |
/dashboard/ |
dashboard |
Superuser internal tools |
/users/ |
users |
User profile and created-content lists |
/accounts/ |
— | django-allauth authentication |
/admin/ |
— | Django admin |
/swagger/ |
— | API docs (admin-only) |
/sitemap.xml |
— | XML sitemap |
/rss/, /atom/ |
— | Feed syndication |
Event Visibility State Machine
An event has four independent boolean flags. The computed is_visible property is the gate for public display.
is_published (contributor) ──┐
├─► is_visible = is_published AND is_approved
is_approved (admin) ────┘
is_featured_on_homepage ──► appears in HomeView featured section
is_hidden ──► hidden from home (bulk import staging)
Permission Architecture
Content Editing (Event, Organizer, Speaker, Place)
def can_edit(self, user):
return user.is_superuser or user == self.created_by or user in self.editors.all()
Applied via EditorPermissionRequiredMixin on update views.
Dashboard
class SuperuserRequiredMixin(UserPassesTestMixin):
def test_func(self):
return self.request.user.is_superuser
All dashboard views inherit this mixin.
API
DRF uses DjangoModelPermissionsOrAnonReadOnly: anonymous users can read; write operations require authentication and model-level Django permissions.
Authentication Stack
axes.backends.AxesStandaloneBackend— brute-force protection (first in chain)desparchado.backends.EmailBackend— custom email logindjango.contrib.auth.backends.ModelBackend— standard Django admin loginallauth.account.auth_backends.AuthenticationBackend— allauth email auth
User Quota System
UserSettings (auto-created via post_save signal on User) enforces daily creation limits:
| Resource | Default quota | Period |
|---|---|---|
| Events | 10 | 24 hours |
| Organizers | 5 | 24 hours |
| Speakers | 5 | 24 hours |
| Places | 5 | 24 hours |
Superusers bypass all quotas. Quota enforcement happens in the dispatch() method of create views (e.g., EventCreateView).
Search Architecture
Full-text search is implemented in events/services/event_search.py:
- PostgreSQL
SearchVectorontitleanddescription(indexed full-text). unaccent__icontainsfor title, description, and speaker names — handles accented characters in Colombian Spanish.SearchQueryfor ranked full-text matching.- Minimum 3-character threshold to avoid noise.
.distinct()to collapse duplicates from the speaker JOIN.
Speaker names are matched via Q(speakers__name__unaccent__icontains=search_str) rather than including them in the SearchVector (which would produce duplicate rows from the M2M join).
External Data Ingestion
Generic Google Sheets Sync (SpreadsheetSyncFormView)
dashboard/services/spreadsheet_sync.py — sync_events():
- Reads rows from a configured Google Sheets range (columns A–J)
- Upserts events via
Event.objects.update_or_create()using eitherevent_source_urlorsource_idas the deduplication key - Resolves organizers and speakers by name (case-insensitive)
- Downloads and attaches event images from URLs
- Links events to a configured
Specialif set - Returns
list[RowProcessingResult]for the dashboard UI to display
FILBo-specific Sync (dashboard/services/filbo.py)
Dedicated importer for the Feria Internacional del Libro de Bogotá:
- Source ID format:
FILBO2026_<numeric_id>(extracted from event URL/descripcion-actividad/<id>/) - Category mapping from FILBo-specific categories to the platform's 5 categories
- Organizer resolution: canonical name mapping via worksheet 2 (FILBO_NAME → CANONICAL_NAME)
- Speaker resolution: whole-word regex matching against participants, title, and description fields
- Place resolution: all FILBo venues created as
<venue_name> | Corferiasat Corferias coordinates - After sync: events with
FILBO2026_prefix not in the current sheet are unpublished
Frontend Architecture
Vue Component Mounting
Django template
└─ <div data-vue-component="event-card" data-vue-prop-title="..." ...>
mount-vue.ts (loads on DOMContentLoaded)
└─ VueComponentMount.mountAll()
└─ Finds all [data-vue-component] elements
└─ Resolves component by kebab-case name
└─ Extracts props from data-vue-prop-* attributes (JSON-parsed)
└─ createApp(component, props).mount(el)
Event List Widget (Homepage)
home.ts
└─ EventContainer (registered by data-url attribute)
└─ getEventList(url) → fetch /events/api/v1/events/future/
└─ mapEventToCardProps(results)
└─ createApp(EventsListApp, { events }).mount(el)
SCSS / Styling
- Component-scoped SCSS files colocated with Vue components
- Global styles in
desparchado/frontend/styles/ - BEM methodology enforced via the
bem()utility - Bootstrap 5 via crispy-forms for Django form rendering
Deployment Architecture
Internet
│
▼
Nginx (reverse proxy + static file serving)
│
▼
Gunicorn (Django WSGI, multiple workers)
│
├── Django app
│ ├── media/ (user uploads)
│ └── desparchado/static/dist/ (Vite build output)
│
▼
PostgreSQL + PostGIS
Production host: desparchado.co
Environment differences
| Setting | Development | Production |
|---|---|---|
DEBUG |
True | False |
| Email backend | Console or SES | AWS SES |
| Sentry | Disabled | Enabled (100% traces) |
| Analytics | Disabled | Umami enabled |
| Vite | Dev server (HMR) | Compiled manifest |
| Cache | LocMemCache | LocMemCache (no Redis) |
Geographic Data
Place.locationandCity.center_locationuse PostGISPointField(SRID 4326 WGS84).- Map widgets in forms use Leaflet.js (OpenStreetMap, no API key) via a custom
LeafletPointFieldWidgetinplaces/widgets/leaflet.py. - Coordinate retrieval:
place.get_longitude_str()/place.get_latitude_str()return stringx/yvalues. - Events expose
get_longitude_str()andget_latitude_str()by delegating to their associated Place.
Testing Strategy
- Unit/integration tests with pytest + pytest-django
- View tests use
django-webtest(django_appfixture) - All tests use
@pytest.mark.django_db - No
TestCaseclasses — standalone function tests only - Database reused between runs (
--reuse-db) - Factories via
factory-boyfor all model creation - Mocking used sparingly; tests hit the real test database
Key Architectural Decisions
- Monolith over microservices: Single codebase simplifies deployment and development for a small team.
- Server-rendered HTML + Vue islands: Progressive enhancement — the site works without JS; Vue adds interactivity where needed.
- PostGIS: Enables future geo-filtering (e.g., events near a point). Currently used for map display.
- Source ID deduplication: External events carry a
source_id(e.g.,FILBO2026_12345) to prevent duplicates across sync runs. - No caching layer (Redis): Uses Django's
LocMemCache. The event list city filter IDs are cached in memory for 24h. - Email-only authentication: No username login for end users; allauth handles the flow.
- Quota system: Prevents abuse from anonymous bulk event creation while allowing superusers to bypass limits.