chore: remove legacy Django app and runtime
The public blog at richardnixon.dev is fully served by the Hugo companion repo (richardnixon.dev-hugo) since the cutover. This commit drops everything Django-related from this repo: - Containers stopped and removed (already done on the host): platform-web, platform-celery, platform-celery-beat, platform-frontend, platform-db, platform-redis. - Volumes dropped: platform-db-data, platform-redis-data, platform-static, platform-media (no backup; DB only held an empty draft). - docker-compose.yml: removed all platform-* services, the platform-internal network, and the postgres-exporter / redis-exporter that only monitored the dropped DB/Redis pair. - traefik/dynamic.yml: removed platform-api router and the platform-api / platform-frontend service definitions. - Source tree: deleted apps/, config/, docker/, frontend/, locale/, templates/, media/, static/, requirements/, manage.py, requirements.txt, conftest.py, pytest.ini. - docs/development.md removed (was 100% Django dev guide). - README and docs/deployment.md rewritten as infra-only references.
This commit is contained in:
parent
1e8c7ccf6f
commit
a87db639eb
128 changed files with 154 additions and 8657 deletions
403
README.md
403
README.md
|
|
@ -2,30 +2,30 @@
|
|||
|
||||
Self-hosted infrastructure (Traefik + Docker Compose) running every service on the `richardnixon.dev` apex and subdomains.
|
||||
|
||||
The public blog at `richardnixon.dev` is now a **static Hugo site** — its source lives in a separate repo: [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo). The Django app in `apps/` here is kept around as **legacy**: it still answers `/admin/`, `/api/`, `/media/`, `/static/` while the database is preserved, but its blog/portfolio/contact views are no longer the public surface.
|
||||
The public blog at `richardnixon.dev` is a **static Hugo site** — its source lives in a companion repo: [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo). Deploys are driven by Forgejo Actions; the runner in this stack builds the site and writes directly into the `blog-static` bind-mount.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
DNS[Cloudflare DNS] --> Traefik[Traefik + SSL/TLS]
|
||||
DNS[Cloudflare DNS] --> Traefik[Traefik + Let's Encrypt]
|
||||
Traefik --> CrowdSec[CrowdSec IPS]
|
||||
|
||||
Traefik --> Blog[Hugo blog-static<br>richardnixon.dev]
|
||||
Traefik --> Platform[Django legacy<br>richardnixon.dev/admin·/api]
|
||||
Traefik --> WP[WordPress<br>richardemanu.com]
|
||||
Traefik --> LocFlow[LocFlow API<br>locflow.richardnixon.dev]
|
||||
Traefik --> EireScope[EireScope<br>eirescope.richardnixon.dev]
|
||||
Traefik --> Forgejo[Forgejo<br>git.richardnixon.dev]
|
||||
Traefik --> Forgejo[Forgejo + runner<br>git.richardnixon.dev]
|
||||
Traefik --> Umami[Umami Analytics<br>analytics.richardnixon.dev]
|
||||
Traefik --> Authentik[Authentik SSO<br>auth.richardnixon.dev]
|
||||
Traefik --> Grafana[Grafana<br>status.richardnixon.dev]
|
||||
Traefik --> Portainer[Portainer<br>portainer.richardnixon.dev]
|
||||
Traefik --> VStatus[Valheim Status<br>valheim.richardnixon.dev]
|
||||
|
||||
Platform --> PlatformDB[(PostgreSQL + Redis<br>+ Celery)]
|
||||
LocFlow --> LocFlowDB[(PostgreSQL)]
|
||||
Umami --> UmamiDB[(PostgreSQL)]
|
||||
Forgejo --> ForgejoDB[(PostgreSQL)]
|
||||
Authentik --> AuthDB[(PostgreSQL + Redis)]
|
||||
WP --> WPDB[(MariaDB)]
|
||||
Grafana --> Prometheus[Prometheus + Loki]
|
||||
VStatus --> Valheim[Valheim Server<br>UDP:2456-2458]
|
||||
|
|
@ -35,56 +35,134 @@ graph TD
|
|||
|
||||
| Service | Domain | Description |
|
||||
|---------|--------|-------------|
|
||||
| Hugo blog (blog-static) | richardnixon.dev | Static blog (Hugo + PaperMod) — content in [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo) |
|
||||
| Django legacy (platform-web) | richardnixon.dev/admin · /api | Admin + API for migration window; will be retired |
|
||||
| Forgejo Actions runner | (internal) | CI runner for Hugo blog deploy (joins `forgejo-internal` network) |
|
||||
| Hugo blog (blog-static) | richardnixon.dev | Static blog (Hugo + PaperMod) — content in [`richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo) |
|
||||
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (Actions enabled, SSO via Authentik) |
|
||||
| Forgejo runner | (internal) | CI runner for Hugo blog deploys |
|
||||
| WordPress | richardemanu.com | Personal site |
|
||||
| LocFlow | locflow.richardnixon.dev | Localization automation platform (REST API) |
|
||||
| EireScope | eirescope.richardnixon.dev | OSINT dashboard |
|
||||
| Forgejo | git.richardnixon.dev | Self-hosted Git forge (public repos, SSO via Authentik, Actions enabled) |
|
||||
| Umami | analytics.richardnixon.dev | Privacy-focused analytics |
|
||||
| Authentik | auth.richardnixon.dev | SSO/OIDC provider |
|
||||
| Grafana | status.richardnixon.dev | Observability dashboards |
|
||||
| Portainer | portainer.richardnixon.dev | Docker management |
|
||||
| Valheim Status | valheim.richardnixon.dev | Game server status page |
|
||||
| Valheim Server | valheim.richardnixon.dev:2456 | Valheim dedicated server |
|
||||
| Valheim Server | valheim.richardnixon.dev:2456 | Dedicated game server |
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Edge
|
||||
- **Traefik v3** as reverse proxy with Let's Encrypt SSL
|
||||
- **CrowdSec** as IPS with community threat intelligence + Traefik bouncer
|
||||
- **Authentik** for SSO/OIDC on admin-only routes
|
||||
|
||||
### Public blog (`richardnixon.dev`)
|
||||
- **Generator**: Hugo 0.123.7 extended + PaperMod theme (pinned `v8.0`)
|
||||
- **Server**: nginx:alpine (`blog-static` service), volume bind-mounted from `/root/richardnixon.dev-hugo/public/`
|
||||
- **CI**: Forgejo Actions on push to `main` of [`Richard/richardnixon.dev-hugo`](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo)
|
||||
- **i18n**: PT-BR (default) + EN, native Hugo language config
|
||||
- **Hugo 0.123.7 extended** + PaperMod theme (pinned `v8.0`)
|
||||
- **nginx:alpine** (`blog-static`) serves `/root/richardnixon.dev-hugo/public/` via bind-mount
|
||||
- **Forgejo Actions** runner builds on push and copies output into the volume
|
||||
|
||||
### Legacy Django app (kept for `/admin` and `/api` only)
|
||||
- **Backend**: Django 5.x, Celery, PostgreSQL 16, Redis 7
|
||||
- **Frontend**: Django Templates, HTMX, TailwindCSS
|
||||
- **Auth**: django-allauth (Google/GitHub OAuth), django-two-factor-auth (2FA)
|
||||
- **i18n**: English + Portuguese (django-modeltranslation)
|
||||
- **Routes**: limited to `/api`, `/admin`, `/static`, `/media` via Traefik dynamic.yml (priority 110); blog/portfolio/contact views are dormant.
|
||||
### Observability
|
||||
- **Prometheus, Grafana, Loki, Promtail, cAdvisor, Node Exporter** as the monitoring stack
|
||||
|
||||
### Infrastructure
|
||||
- **Reverse Proxy**: Traefik v3 with Let's Encrypt SSL
|
||||
- **Containers**: Docker Compose
|
||||
- **Monitoring**: Prometheus, Grafana, Loki, Promtail, cAdvisor
|
||||
- **Exporters**: Node Exporter, PostgreSQL Exporter, Redis Exporter
|
||||
|
||||
### Security
|
||||
- **CrowdSec**: IPS with community threat intelligence + Traefik bouncer
|
||||
### Host hardening
|
||||
- **Fail2ban**: SSH brute-force protection (3 attempts = 24h ban)
|
||||
- **GeoIP Blocking**: Country-based filtering for SSH
|
||||
- **reCAPTCHA v3**: Contact form spam protection
|
||||
- **GeoIP**: country-based filtering for SSH
|
||||
- **CrowdSec**: web + SSH attack detection
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
richardnixon.dev/
|
||||
├── docs/
|
||||
│ └── deployment.md
|
||||
├── infrastructure/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.example
|
||||
│ ├── traefik/
|
||||
│ │ ├── traefik.yml
|
||||
│ │ └── dynamic.yml
|
||||
│ ├── blog-static/
|
||||
│ │ └── nginx.conf
|
||||
│ ├── forgejo-runner/
|
||||
│ │ ├── config.yml
|
||||
│ │ └── entrypoint.sh
|
||||
│ ├── prometheus/
|
||||
│ ├── loki/
|
||||
│ ├── promtail/
|
||||
│ ├── grafana/
|
||||
│ │ └── provisioning/
|
||||
│ ├── crowdsec/
|
||||
│ └── valheim-status/
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose v2
|
||||
- Domain with DNS configured
|
||||
|
||||
### Deployment
|
||||
|
||||
1. Clone both repositories:
|
||||
```bash
|
||||
cd /root
|
||||
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
|
||||
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git
|
||||
```
|
||||
|
||||
2. Configure environment:
|
||||
```bash
|
||||
cd richardnixon.dev/infrastructure
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Traefik provisions Let's Encrypt certificates on first start. Detailed steps live in [`docs/deployment.md`](docs/deployment.md), including Forgejo Actions runner registration.
|
||||
|
||||
## URL Routes
|
||||
|
||||
Configured in `infrastructure/traefik/dynamic.yml`.
|
||||
|
||||
| Path | Backend | Notes |
|
||||
|------|---------|-------|
|
||||
| `/` | blog-static (Hugo) | Redirects to `/pt-br/` (default content language) |
|
||||
| `/pt-br/*`, `/en/*` | blog-static (Hugo) | Bilingual blog content |
|
||||
| `/feed.xml`, `/sitemap.xml`, `/robots.txt` | blog-static (Hugo) | Static SEO/feed files |
|
||||
|
||||
All other domains route by `Host()` rule to their respective services.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `infrastructure/.env.example` for the full list. Key categories:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| Forgejo | `FORGEJO_DB_PASSWORD`, `FORGEJO_SECRET_KEY`, `FORGEJO_INTERNAL_TOKEN`, `FORGEJO_OAUTH2_JWT_SECRET`, `FORGEJO_RUNNER_REGISTRATION_TOKEN` |
|
||||
| LocFlow | `LOCFLOW_DB_PASSWORD`, `LOCFLOW_SECRET_KEY`, `LOCFLOW_UMAMI_WEBSITE_ID` |
|
||||
| EireScope | `EIRESCOPE_SECRET_KEY`, `EIRESCOPE_UMAMI_WEBSITE_ID` |
|
||||
| Authentik | `AUTHENTIK_SECRET_KEY`, `AUTHENTIK_DB_PASSWORD` |
|
||||
| Umami | `UMAMI_DB_PASSWORD`, `UMAMI_HASH_SALT` |
|
||||
| WordPress | `MYSQL_ROOT_PASSWORD`, `WP_DB_PASSWORD` |
|
||||
| CrowdSec | `CROWDSEC_BOUNCER_KEY` |
|
||||
| Grafana | `GRAFANA_ADMIN_PASSWORD` |
|
||||
| Valheim | `VALHEIM_PASSWORD`, `ADMINLIST_IDS`, `PERMITTEDLIST_IDS` |
|
||||
|
||||
## Grafana Dashboards
|
||||
|
||||
URL: https://status.richardnixon.dev
|
||||
|
||||
| Dashboard | Description |
|
||||
|-----------|-------------|
|
||||
| VPS System | CPU, memory, disk, load average, uptime |
|
||||
| CrowdSec Security | Active bans, alerts, attack types |
|
||||
| Traefik Proxy | Requests/s, latency, status codes |
|
||||
| Database | PostgreSQL and Redis metrics |
|
||||
| Network & Firewall | Bandwidth, TCP connections, security events |
|
||||
| Container Logs | Real-time log viewer with search |
|
||||
| Container Logs | Real-time log viewer with search (Loki-backed) |
|
||||
| Valheim Server | Players online, server status, resource usage |
|
||||
|
||||
## Prometheus Metrics
|
||||
|
|
@ -95,161 +173,39 @@ graph TD
|
|||
| cadvisor | cadvisor:8080 | Container metrics |
|
||||
| traefik | traefik:8080 | HTTP requests, latency |
|
||||
| node | node-exporter:9100 | VPS system metrics |
|
||||
| postgres | postgres-exporter:9187 | PostgreSQL stats |
|
||||
| redis | redis-exporter:9121 | Redis stats |
|
||||
| crowdsec | crowdsec:6060 | Security metrics |
|
||||
| valheim | valheim-metrics:3903 | Game server metrics |
|
||||
| forgejo | forgejo:3000 | Git forge metrics (repos, users, HTTP) |
|
||||
|
||||
## Project Structure
|
||||
## Forgejo Actions (Hugo blog CI)
|
||||
|
||||
```
|
||||
richardnixon.dev/
|
||||
├── apps/
|
||||
│ ├── accounts/ # Custom user model (email-based)
|
||||
│ ├── blog/ # Blog posts, Markdown, RSS
|
||||
│ ├── portfolio/ # Projects showcase
|
||||
│ └── contact/ # Contact form with reCAPTCHA
|
||||
├── config/
|
||||
│ ├── settings/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── development.py
|
||||
│ │ └── production.py
|
||||
│ ├── urls.py
|
||||
│ ├── celery.py
|
||||
│ └── wsgi.py
|
||||
├── templates/
|
||||
├── static/
|
||||
├── locale/ # i18n translations
|
||||
├── docker/
|
||||
│ ├── Dockerfile
|
||||
│ └── Dockerfile.full
|
||||
└── infrastructure/
|
||||
├── docker-compose.yml
|
||||
├── .env.example
|
||||
├── traefik/
|
||||
├── prometheus/
|
||||
├── loki/
|
||||
├── promtail/
|
||||
├── grafana/
|
||||
│ └── provisioning/
|
||||
│ ├── datasources/
|
||||
│ └── dashboards/
|
||||
├── crowdsec/
|
||||
└── valheim-status/
|
||||
```
|
||||
The `forgejo-runner` service registers itself on first boot using `FORGEJO_RUNNER_REGISTRATION_TOKEN` and joins the `forgejo-internal` network so job containers can resolve the internal `forgejo` hostname for `actions/checkout`.
|
||||
|
||||
## Quick Start
|
||||
Config (`infrastructure/forgejo-runner/config.yml`):
|
||||
- `container.network: infrastructure_forgejo-internal` — without this, checkout fails to resolve `forgejo`.
|
||||
- `container.docker_host: unix:///var/run/docker.sock` — required value; `"automatic"` is not accepted by recent runner versions.
|
||||
- `valid_volumes: ["/root/richardnixon.dev-hugo/public"]` — the only host path the workflow is allowed to write to.
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Domain with DNS configured
|
||||
The deploy workflow itself lives in the **Hugo repo** at `.forgejo/workflows/deploy.yml`. Pushing to its `main` branch builds Hugo (downloaded fresh per run) and copies `public/` into the bind-mount served by `blog-static`. End-to-end push → live is ~30–60s.
|
||||
|
||||
### Deployment
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://git.richardnixon.dev/Richard/richardnixon.dev.git
|
||||
cd richardnixon.dev/infrastructure
|
||||
```
|
||||
|
||||
For the public blog content (Hugo source), clone the companion repo:
|
||||
```bash
|
||||
git clone --recurse-submodules https://git.richardnixon.dev/Richard/richardnixon.dev-hugo.git
|
||||
```
|
||||
|
||||
2. Create environment file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your credentials
|
||||
```
|
||||
|
||||
3. Start services:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
4. Run migrations:
|
||||
```bash
|
||||
docker compose exec platform-web python manage.py migrate
|
||||
docker compose exec platform-web python manage.py createsuperuser
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `infrastructure/.env.example` for all required variables:
|
||||
|
||||
### Django
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DJANGO_SECRET_KEY` | Django secret key |
|
||||
| `PLATFORM_DB_PASSWORD` | PostgreSQL password |
|
||||
| `DJANGO_SUPERUSER_EMAIL` | Admin email (optional) |
|
||||
| `RECAPTCHA_PUBLIC_KEY` | reCAPTCHA v3 site key |
|
||||
| `RECAPTCHA_PRIVATE_KEY` | reCAPTCHA v3 secret key |
|
||||
|
||||
### Monitoring
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GRAFANA_ADMIN_PASSWORD` | Grafana admin password |
|
||||
| `CROWDSEC_BOUNCER_KEY` | CrowdSec bouncer API key |
|
||||
|
||||
### LocFlow
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `LOCFLOW_DB_PASSWORD` | LocFlow PostgreSQL password |
|
||||
| `LOCFLOW_SECRET_KEY` | LocFlow Django secret key |
|
||||
| `LOCFLOW_UMAMI_WEBSITE_ID` | Umami website ID for tracking |
|
||||
|
||||
### EireScope
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `EIRESCOPE_SECRET_KEY` | EireScope secret key |
|
||||
| `EIRESCOPE_UMAMI_WEBSITE_ID` | Umami website ID for tracking |
|
||||
|
||||
### Forgejo
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `FORGEJO_DB_PASSWORD` | Forgejo PostgreSQL password |
|
||||
| `FORGEJO_SECRET_KEY` | Internal session/cookie secret |
|
||||
| `FORGEJO_INTERNAL_TOKEN` | API token for internal RPC |
|
||||
| `FORGEJO_OAUTH2_JWT_SECRET` | JWT secret for Forgejo's OAuth2 provider |
|
||||
| `FORGEJO_OIDC_CLIENT_ID` | Authentik OIDC client ID (optional, for SSO) |
|
||||
| `FORGEJO_OIDC_CLIENT_SECRET` | Authentik OIDC client secret (optional, for SSO) |
|
||||
|
||||
### Valheim
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `VALHEIM_PASSWORD` | Server password |
|
||||
| `ADMINLIST_IDS` | Comma-separated Steam IDs of admins |
|
||||
| `PERMITTEDLIST_IDS` | Comma-separated Steam IDs for whitelist |
|
||||
|
||||
## Security Configuration
|
||||
## Security
|
||||
|
||||
### CrowdSec
|
||||
|
||||
Installed collections:
|
||||
- `crowdsecurity/traefik` - Web attack detection
|
||||
- `crowdsecurity/http-cve` - CVE exploit detection
|
||||
- `crowdsecurity/linux` - Linux system protection
|
||||
- `crowdsecurity/whitelist-good-actors` - CDN/bot whitelist
|
||||
- `crowdsecurity/traefik`
|
||||
- `crowdsecurity/http-cve`
|
||||
- `crowdsecurity/linux`
|
||||
- `crowdsecurity/whitelist-good-actors`
|
||||
|
||||
Country blocks (web traffic):
|
||||
- Russia (RU), China (CN), North Korea (KP), Iran (IR), Ukraine (UA)
|
||||
Country blocks (web traffic): RU, CN, KP, IR, UA.
|
||||
|
||||
Useful commands:
|
||||
```bash
|
||||
# View metrics
|
||||
docker exec crowdsec cscli metrics
|
||||
|
||||
# List active bans
|
||||
docker exec crowdsec cscli decisions list
|
||||
|
||||
# Add manual ban
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --duration 24h --reason "manual"
|
||||
|
||||
# View alerts
|
||||
docker exec crowdsec cscli alerts list
|
||||
docker exec crowdsec cscli decisions add --ip 1.2.3.4 --duration 24h --reason "manual"
|
||||
```
|
||||
|
||||
### SSH Security
|
||||
|
|
@ -293,119 +249,36 @@ info - Server info
|
|||
|
||||
### Managing Admins
|
||||
|
||||
Add Steam IDs to docker-compose.yml:
|
||||
```yaml
|
||||
ADMINLIST_IDS: "76561198012345678,76561198087654321"
|
||||
PERMITTEDLIST_IDS: "76561198012345678,76561198087654321"
|
||||
Edit `infrastructure/.env`:
|
||||
```
|
||||
ADMINLIST_IDS=76561198012345678,76561198087654321
|
||||
PERMITTEDLIST_IDS=76561198012345678,76561198087654321
|
||||
```
|
||||
|
||||
Then restart the server:
|
||||
Then restart:
|
||||
```bash
|
||||
docker compose up -d valheim --force-recreate
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Grafana Access
|
||||
|
||||
URL: https://status.richardnixon.dev
|
||||
|
||||
### Log Aggregation
|
||||
|
||||
Loki + Promtail collect logs from:
|
||||
- All Docker containers
|
||||
- System logs (/var/log)
|
||||
|
||||
Query logs in Grafana:
|
||||
```
|
||||
{container_name="platform-web"} |~ "error"
|
||||
{job="docker"} |= "blocked"
|
||||
```
|
||||
|
||||
## Common Commands
|
||||
|
||||
### Docker Management
|
||||
```bash
|
||||
# Start all services
|
||||
docker compose up -d
|
||||
|
||||
# View container status
|
||||
# Status
|
||||
docker compose ps
|
||||
|
||||
# View logs
|
||||
docker compose logs -f platform-web
|
||||
docker compose logs -f platform-celery
|
||||
docker compose logs -f locflow-web
|
||||
# Logs
|
||||
docker compose logs -f <service>
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose build platform-web platform-celery platform-celery-beat
|
||||
docker compose up -d platform-web platform-celery platform-celery-beat
|
||||
# Restart a service
|
||||
docker compose restart <service>
|
||||
|
||||
# Rebuild LocFlow
|
||||
docker compose build locflow-web
|
||||
docker compose up -d locflow-web
|
||||
# Rebuild a service that has a build context
|
||||
docker compose build <service> && docker compose up -d <service>
|
||||
|
||||
# Rebuild EireScope
|
||||
docker compose build eirescope
|
||||
docker compose up -d eirescope
|
||||
# Pull image updates
|
||||
docker compose pull && docker compose up -d
|
||||
```
|
||||
|
||||
### Django Management (Platform)
|
||||
```bash
|
||||
docker compose exec platform-web python manage.py migrate
|
||||
docker compose exec platform-web python manage.py makemigrations
|
||||
docker compose exec platform-web python manage.py createsuperuser
|
||||
docker compose exec platform-web python manage.py collectstatic
|
||||
docker compose exec platform-web python manage.py shell
|
||||
```
|
||||
|
||||
### Django Management (LocFlow)
|
||||
```bash
|
||||
docker compose exec locflow-web python manage.py migrate
|
||||
docker compose exec locflow-web python manage.py createsuperuser
|
||||
docker compose exec locflow-web python manage.py shell
|
||||
```
|
||||
|
||||
### Security Management
|
||||
```bash
|
||||
# Check fail2ban status
|
||||
fail2ban-client status sshd
|
||||
|
||||
# View CrowdSec decisions
|
||||
docker exec crowdsec cscli decisions list
|
||||
|
||||
# View iptables rules
|
||||
iptables -L INPUT -n -v
|
||||
```
|
||||
|
||||
### Valheim Management
|
||||
```bash
|
||||
# View server logs
|
||||
docker compose logs -f valheim
|
||||
|
||||
# Restart server
|
||||
docker compose restart valheim
|
||||
|
||||
# Backup world manually
|
||||
docker exec valheim backup
|
||||
|
||||
# Check connected players
|
||||
docker logs valheim 2>&1 | grep "Got connection"
|
||||
```
|
||||
|
||||
## URL Routes (post-Hugo cutover)
|
||||
|
||||
Routing is configured in `infrastructure/traefik/dynamic.yml`.
|
||||
|
||||
| Path | Backend | Notes |
|
||||
|------|---------|-------|
|
||||
| `/` | blog-static (Hugo) | Redirects to `/pt-br/` (default content language) |
|
||||
| `/pt-br/...`, `/en/...` | blog-static (Hugo) | Bilingual blog content |
|
||||
| `/feed.xml`, `/sitemap.xml` | blog-static (Hugo) | Per-language RSS and sitemap index |
|
||||
| `/admin/` | platform-web (Django) | Admin interface (2FA) — priority 110 |
|
||||
| `/api/` | platform-web (Django) | REST stub for legacy clients |
|
||||
| `/static/`, `/media/` | platform-web (Django) | Legacy static/media uploads |
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
# Django Apps
|
||||
|
||||
## accounts
|
||||
|
||||
Custom user model with email-based authentication (no username). Roles: `owner` and `registered`. Extends `AbstractBaseUser` + `PermissionsMixin`. Used by django-allauth for Google/GitHub OAuth and django-two-factor-auth for 2FA/OTP.
|
||||
|
||||
## api
|
||||
|
||||
REST API powered by django-ninja. Exposes endpoints for blog posts, portfolio projects, contact form, and homepage data. Consumed by the Next.js frontend (`frontend/`).
|
||||
|
||||
## blog
|
||||
|
||||
Blog engine with posts, tags, and categories. Features:
|
||||
- Markdown rendering with Pygments syntax highlighting
|
||||
- CKEditor 5 rich text editor in admin
|
||||
- RSS feed (`/feed/`)
|
||||
- SEO sitemap (`sitemaps.py`)
|
||||
- i18n support via django-modeltranslation
|
||||
|
||||
## contact
|
||||
|
||||
Contact form with model-backed storage and reCAPTCHA v3 validation. Includes a resume download view.
|
||||
|
||||
## core
|
||||
|
||||
Lightweight utility app. Contains the `/health/` endpoint for Docker healthchecks and monitoring.
|
||||
|
||||
## portfolio
|
||||
|
||||
Projects showcase with technologies/tags. Features:
|
||||
- SEO sitemap (`sitemaps.py`)
|
||||
- i18n support via django-modeltranslation
|
||||
- Related projects
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Apps package
|
||||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'apps.accounts.apps.AccountsConfig'
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
|
||||
from .models import User
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class UserAdmin(BaseUserAdmin):
|
||||
list_display = ('email', 'name', 'role', 'is_active', 'is_staff', 'created_at')
|
||||
list_filter = ('role', 'is_active', 'is_staff', 'is_superuser')
|
||||
search_fields = ('email', 'name')
|
||||
ordering = ('-created_at',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {'fields': ('email', 'password')}),
|
||||
('Personal Info', {'fields': ('name', 'avatar')}),
|
||||
('Permissions', {'fields': ('role', 'is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
|
||||
('Dates', {'fields': ('last_login',)}),
|
||||
)
|
||||
|
||||
add_fieldsets = (
|
||||
(None, {
|
||||
'classes': ('wide',),
|
||||
'fields': ('email', 'password1', 'password2', 'name', 'role'),
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.accounts'
|
||||
verbose_name = 'Accounts'
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 00:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='User',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('password', models.CharField(max_length=128, verbose_name='password')),
|
||||
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
|
||||
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
|
||||
('email', models.EmailField(max_length=254, unique=True)),
|
||||
('name', models.CharField(blank=True, max_length=150)),
|
||||
('avatar', models.URLField(blank=True)),
|
||||
('role', models.CharField(choices=[('owner', 'Owner'), ('registered', 'Registered User')], default='registered', max_length=20)),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('is_staff', models.BooleanField(default=False)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
|
||||
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'user',
|
||||
'verbose_name_plural': 'users',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
from django.db import models
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
|
||||
|
||||
|
||||
class UserManager(BaseUserManager):
|
||||
def create_user(self, email, password=None, **extra_fields):
|
||||
if not email:
|
||||
raise ValueError('Email is required')
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save(using=self._db)
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, password=None, **extra_fields):
|
||||
extra_fields.setdefault('is_staff', True)
|
||||
extra_fields.setdefault('is_superuser', True)
|
||||
extra_fields.setdefault('is_active', True)
|
||||
|
||||
if extra_fields.get('is_staff') is not True:
|
||||
raise ValueError('Superuser must have is_staff=True.')
|
||||
if extra_fields.get('is_superuser') is not True:
|
||||
raise ValueError('Superuser must have is_superuser=True.')
|
||||
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class User(AbstractBaseUser, PermissionsMixin):
|
||||
"""Custom user model using email as identifier."""
|
||||
|
||||
class Role(models.TextChoices):
|
||||
OWNER = 'owner', 'Owner'
|
||||
REGISTERED = 'registered', 'Registered User'
|
||||
|
||||
email = models.EmailField(unique=True)
|
||||
name = models.CharField(max_length=150, blank=True)
|
||||
avatar = models.URLField(blank=True)
|
||||
role = models.CharField(max_length=20, choices=Role.choices, default=Role.REGISTERED)
|
||||
is_active = models.BooleanField(default=True)
|
||||
is_staff = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
objects = UserManager()
|
||||
|
||||
USERNAME_FIELD = 'email'
|
||||
REQUIRED_FIELDS = []
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'user'
|
||||
verbose_name_plural = 'users'
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def get_full_name(self):
|
||||
return self.name or self.email
|
||||
|
||||
def get_short_name(self):
|
||||
return self.name.split()[0] if self.name else self.email.split('@')[0]
|
||||
|
||||
@property
|
||||
def is_owner(self):
|
||||
return self.role == self.Role.OWNER
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestUserModel:
|
||||
def test_create_user_with_email(self):
|
||||
user = User.objects.create_user(email="user@example.com", password="pass1234")
|
||||
assert user.email == "user@example.com"
|
||||
assert user.check_password("pass1234")
|
||||
assert not user.is_staff
|
||||
assert not user.is_superuser
|
||||
assert user.is_active
|
||||
|
||||
def test_create_user_without_email_raises(self):
|
||||
with pytest.raises(ValueError, match="Email is required"):
|
||||
User.objects.create_user(email="", password="pass1234")
|
||||
|
||||
def test_create_superuser(self):
|
||||
user = User.objects.create_superuser(email="admin@example.com", password="admin1234")
|
||||
assert user.is_staff
|
||||
assert user.is_superuser
|
||||
assert user.is_active
|
||||
|
||||
def test_create_superuser_not_staff_raises(self):
|
||||
with pytest.raises(ValueError, match="is_staff=True"):
|
||||
User.objects.create_superuser(email="a@b.com", password="x", is_staff=False)
|
||||
|
||||
def test_create_superuser_not_superuser_raises(self):
|
||||
with pytest.raises(ValueError, match="is_superuser=True"):
|
||||
User.objects.create_superuser(email="a@b.com", password="x", is_superuser=False)
|
||||
|
||||
def test_email_is_normalized(self):
|
||||
user = User.objects.create_user(email="User@EXAMPLE.COM", password="pass1234")
|
||||
assert user.email == "User@example.com"
|
||||
|
||||
def test_str_returns_email(self):
|
||||
user = User.objects.create_user(email="user@test.com", password="pass1234")
|
||||
assert str(user) == "user@test.com"
|
||||
|
||||
def test_username_field_is_email(self):
|
||||
assert User.USERNAME_FIELD == "email"
|
||||
|
||||
def test_is_owner_property(self):
|
||||
user = User.objects.create_user(email="owner@test.com", password="pass1234", role="owner")
|
||||
assert user.is_owner
|
||||
regular = User.objects.create_user(email="regular@test.com", password="pass1234")
|
||||
assert not regular.is_owner
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
from ninja import NinjaAPI
|
||||
from .routes import blog_router, portfolio_router, contact_router, home_router
|
||||
|
||||
api = NinjaAPI(
|
||||
title="richardnixon.dev API",
|
||||
version="1.0.0",
|
||||
urls_namespace="api",
|
||||
)
|
||||
|
||||
api.add_router("/home", home_router)
|
||||
api.add_router("/blog", blog_router)
|
||||
api.add_router("/portfolio", portfolio_router)
|
||||
api.add_router("/contact", contact_router)
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404
|
||||
from ninja import Router, Query
|
||||
|
||||
from apps.blog.models import BlogPost, Tag
|
||||
from apps.portfolio.models import Project, Technology
|
||||
from apps.contact.models import ContactMessage, Resume
|
||||
from .schemas import (
|
||||
TagSchema, PostListSchema, PostDetailSchema, RelatedPostSchema,
|
||||
TechnologySchema, ProjectListSchema, ProjectDetailSchema,
|
||||
ContactSubmitSchema, ContactResponseSchema, ResumeSchema,
|
||||
HomeDataSchema,
|
||||
)
|
||||
|
||||
blog_router = Router(tags=["blog"])
|
||||
portfolio_router = Router(tags=["portfolio"])
|
||||
contact_router = Router(tags=["contact"])
|
||||
home_router = Router(tags=["home"])
|
||||
|
||||
|
||||
# ---- Home ----
|
||||
|
||||
@home_router.get("/", response=HomeDataSchema)
|
||||
def home(request):
|
||||
posts = (
|
||||
BlogPost.objects.filter(status="published", is_private=False)
|
||||
.select_related("author")
|
||||
.prefetch_related("tags")
|
||||
.order_by("-published_at")[:5]
|
||||
)
|
||||
return {"recent_posts": posts}
|
||||
|
||||
|
||||
# ---- Blog ----
|
||||
|
||||
@blog_router.get("/posts", response=list[PostListSchema])
|
||||
def list_posts(
|
||||
request,
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(10, ge=1, le=50),
|
||||
search: str = Query(""),
|
||||
tag: str = Query(""),
|
||||
):
|
||||
qs = (
|
||||
BlogPost.objects.filter(status="published", is_private=False)
|
||||
.select_related("author")
|
||||
.prefetch_related("tags")
|
||||
.order_by("-published_at")
|
||||
)
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=search)
|
||||
| Q(content__icontains=search)
|
||||
| Q(tags__name__icontains=search)
|
||||
).distinct()
|
||||
if tag:
|
||||
qs = qs.filter(tags__slug=tag)
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
return qs[offset : offset + page_size]
|
||||
|
||||
|
||||
@blog_router.get("/posts/count", response={200: int})
|
||||
def posts_count(request, search: str = Query(""), tag: str = Query("")):
|
||||
qs = BlogPost.objects.filter(status="published", is_private=False)
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(title__icontains=search)
|
||||
| Q(content__icontains=search)
|
||||
| Q(tags__name__icontains=search)
|
||||
).distinct()
|
||||
if tag:
|
||||
qs = qs.filter(tags__slug=tag)
|
||||
return qs.count()
|
||||
|
||||
|
||||
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
||||
def get_post(request, slug: str):
|
||||
post = get_object_or_404(
|
||||
BlogPost.objects.select_related("author").prefetch_related("tags"),
|
||||
slug=slug,
|
||||
status="published",
|
||||
)
|
||||
return post
|
||||
|
||||
|
||||
@blog_router.get("/posts/{slug}/related", response=list[RelatedPostSchema])
|
||||
def related_posts(request, slug: str):
|
||||
post = get_object_or_404(BlogPost, slug=slug, status="published")
|
||||
related = (
|
||||
BlogPost.objects.filter(
|
||||
status="published", is_private=False, tags__in=post.tags.all()
|
||||
)
|
||||
.exclude(pk=post.pk)
|
||||
.distinct()[:3]
|
||||
)
|
||||
return related
|
||||
|
||||
|
||||
@blog_router.get("/tags", response=list[TagSchema])
|
||||
def list_tags(request):
|
||||
return Tag.objects.all().order_by("name")
|
||||
|
||||
|
||||
# ---- Portfolio ----
|
||||
|
||||
@portfolio_router.get("/projects", response=list[ProjectListSchema])
|
||||
def list_projects(request, tech: str = Query("")):
|
||||
qs = (
|
||||
Project.objects.filter(status="published")
|
||||
.prefetch_related("technologies")
|
||||
.order_by("order", "-created_at")
|
||||
)
|
||||
if tech:
|
||||
qs = qs.filter(technologies__slug=tech)
|
||||
return qs
|
||||
|
||||
|
||||
@portfolio_router.get("/projects/featured", response=list[ProjectListSchema])
|
||||
def featured_projects(request):
|
||||
return (
|
||||
Project.objects.filter(status="published", is_featured=True)
|
||||
.prefetch_related("technologies")
|
||||
.order_by("order")[:3]
|
||||
)
|
||||
|
||||
|
||||
@portfolio_router.get("/projects/{slug}", response=ProjectDetailSchema)
|
||||
def get_project(request, slug: str):
|
||||
return get_object_or_404(
|
||||
Project.objects.prefetch_related("technologies", "images"),
|
||||
slug=slug,
|
||||
status="published",
|
||||
)
|
||||
|
||||
|
||||
@portfolio_router.get("/projects/{slug}/related", response=list[ProjectListSchema])
|
||||
def related_projects(request, slug: str):
|
||||
project = get_object_or_404(Project, slug=slug, status="published")
|
||||
related = (
|
||||
Project.objects.filter(
|
||||
status="published", technologies__in=project.technologies.all()
|
||||
)
|
||||
.exclude(pk=project.pk)
|
||||
.prefetch_related("technologies")
|
||||
.distinct()[:3]
|
||||
)
|
||||
return related
|
||||
|
||||
|
||||
@portfolio_router.get("/technologies", response=list[TechnologySchema])
|
||||
def list_technologies(request):
|
||||
return Technology.objects.all().order_by("name")
|
||||
|
||||
|
||||
# ---- Contact ----
|
||||
|
||||
@contact_router.post("/submit", response=ContactResponseSchema)
|
||||
def submit_contact(request, data: ContactSubmitSchema):
|
||||
msg = ContactMessage.objects.create(
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
subject=data.subject,
|
||||
message=data.message,
|
||||
ip_address=request.META.get("HTTP_X_FORWARDED_FOR", request.META.get("REMOTE_ADDR", "")),
|
||||
user_agent=request.META.get("HTTP_USER_AGENT", ""),
|
||||
referrer=request.META.get("HTTP_REFERER", ""),
|
||||
)
|
||||
if msg.is_likely_spam:
|
||||
msg.status = "spam"
|
||||
msg.save()
|
||||
return {"success": True, "message": "Message sent successfully."}
|
||||
|
||||
|
||||
@contact_router.get("/resume", response={200: ResumeSchema, 404: ContactResponseSchema})
|
||||
def get_resume(request):
|
||||
resume = Resume.objects.filter(is_active=True).first()
|
||||
if not resume:
|
||||
return 404, {"success": False, "message": "No resume available."}
|
||||
resume.increment_download()
|
||||
return resume
|
||||
|
|
@ -1,144 +0,0 @@
|
|||
import os
|
||||
from datetime import datetime
|
||||
from ninja import Schema
|
||||
|
||||
SITE_URL = os.environ.get("SITE_URL", "https://richardnixon.dev")
|
||||
|
||||
|
||||
def _abs_url(field_file):
|
||||
if not field_file:
|
||||
return None
|
||||
return f"{SITE_URL}{field_file.url}"
|
||||
|
||||
|
||||
# ---- Blog ----
|
||||
|
||||
class TagSchema(Schema):
|
||||
name: str
|
||||
slug: str
|
||||
description: str
|
||||
|
||||
|
||||
class PostListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str
|
||||
featured_image: str | None
|
||||
tags: list[TagSchema]
|
||||
reading_time: int
|
||||
published_at: datetime | None
|
||||
author_name: str | None
|
||||
|
||||
@staticmethod
|
||||
def resolve_featured_image(obj):
|
||||
return _abs_url(obj.featured_image)
|
||||
|
||||
@staticmethod
|
||||
def resolve_author_name(obj):
|
||||
return obj.author.get_short_name() if obj.author else None
|
||||
|
||||
|
||||
class PostDetailSchema(PostListSchema):
|
||||
content: str
|
||||
meta_description: str
|
||||
meta_keywords: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RelatedPostSchema(Schema):
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str
|
||||
featured_image: str | None
|
||||
reading_time: int
|
||||
published_at: datetime | None
|
||||
|
||||
@staticmethod
|
||||
def resolve_featured_image(obj):
|
||||
return _abs_url(obj.featured_image)
|
||||
|
||||
|
||||
# ---- Portfolio ----
|
||||
|
||||
class TechnologySchema(Schema):
|
||||
name: str
|
||||
slug: str
|
||||
icon: str
|
||||
color: str
|
||||
|
||||
|
||||
class ProjectListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
tagline: str
|
||||
featured_image: str | None
|
||||
thumbnail: str | None
|
||||
technologies: list[TechnologySchema]
|
||||
live_url: str
|
||||
github_url: str
|
||||
is_featured: bool
|
||||
is_ongoing: bool
|
||||
start_date: datetime | None
|
||||
end_date: datetime | None
|
||||
|
||||
@staticmethod
|
||||
def resolve_featured_image(obj):
|
||||
return _abs_url(obj.featured_image)
|
||||
|
||||
@staticmethod
|
||||
def resolve_thumbnail(obj):
|
||||
return _abs_url(obj.thumbnail)
|
||||
|
||||
|
||||
class ProjectImageSchema(Schema):
|
||||
image: str
|
||||
caption: str
|
||||
order: int
|
||||
|
||||
@staticmethod
|
||||
def resolve_image(obj):
|
||||
return _abs_url(obj.image)
|
||||
|
||||
|
||||
class ProjectDetailSchema(ProjectListSchema):
|
||||
description: str
|
||||
documentation_url: str
|
||||
images: list[ProjectImageSchema]
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def resolve_images(obj):
|
||||
return obj.images.all().order_by('order')
|
||||
|
||||
|
||||
# ---- Contact ----
|
||||
|
||||
class ContactSubmitSchema(Schema):
|
||||
name: str
|
||||
email: str
|
||||
subject: str
|
||||
message: str
|
||||
|
||||
|
||||
class ContactResponseSchema(Schema):
|
||||
success: bool
|
||||
message: str
|
||||
|
||||
|
||||
class ResumeSchema(Schema):
|
||||
title: str
|
||||
file_url: str
|
||||
|
||||
@staticmethod
|
||||
def resolve_file_url(obj):
|
||||
return _abs_url(obj.file)
|
||||
|
||||
|
||||
# ---- Home ----
|
||||
|
||||
class HomeDataSchema(Schema):
|
||||
recent_posts: list[PostListSchema]
|
||||
|
|
@ -1,220 +0,0 @@
|
|||
from django.test import TestCase, Client
|
||||
from apps.blog.models import BlogPost, Tag
|
||||
from apps.portfolio.models import Project, Technology
|
||||
from apps.contact.models import ContactMessage, Resume
|
||||
from apps.accounts.models import User
|
||||
|
||||
|
||||
class APITestBase(TestCase):
|
||||
def setUp(self):
|
||||
self.client = Client()
|
||||
self.user = User.objects.create_user(
|
||||
email="test@example.com", password="testpass123"
|
||||
)
|
||||
self.tag = Tag.objects.create(name="Python", description="Python programming")
|
||||
self.post = BlogPost.objects.create(
|
||||
title="Test Post",
|
||||
content="<p>Test content</p>",
|
||||
excerpt="Test excerpt",
|
||||
author=self.user,
|
||||
status="published",
|
||||
)
|
||||
self.post.tags.add(self.tag)
|
||||
|
||||
self.tech = Technology.objects.create(
|
||||
name="Django", icon="django", color="#092E20"
|
||||
)
|
||||
self.project = Project.objects.create(
|
||||
title="Test Project",
|
||||
tagline="A test project",
|
||||
description="<p>Description</p>",
|
||||
status="published",
|
||||
is_featured=True,
|
||||
)
|
||||
self.project.technologies.add(self.tech)
|
||||
|
||||
|
||||
class HomeAPITest(APITestBase):
|
||||
def test_home(self):
|
||||
r = self.client.get("/api/home/")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertIn("recent_posts", data)
|
||||
self.assertEqual(len(data["recent_posts"]), 1)
|
||||
self.assertEqual(data["recent_posts"][0]["title"], "Test Post")
|
||||
|
||||
|
||||
class BlogAPITest(APITestBase):
|
||||
def test_list_posts(self):
|
||||
r = self.client.get("/api/blog/posts")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["title"], "Test Post")
|
||||
self.assertEqual(data[0]["tags"][0]["name"], "Python")
|
||||
|
||||
def test_list_posts_search(self):
|
||||
r = self.client.get("/api/blog/posts?search=Test")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_list_posts_search_no_match(self):
|
||||
r = self.client.get("/api/blog/posts?search=nonexistent")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 0)
|
||||
|
||||
def test_list_posts_tag_filter(self):
|
||||
r = self.client.get("/api/blog/posts?tag=python")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_posts_count(self):
|
||||
r = self.client.get("/api/blog/posts/count")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(r.json(), 1)
|
||||
|
||||
def test_get_post(self):
|
||||
r = self.client.get(f"/api/blog/posts/{self.post.slug}")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertEqual(data["title"], "Test Post")
|
||||
self.assertEqual(data["content"], "<p>Test content</p>")
|
||||
|
||||
def test_get_post_not_found(self):
|
||||
r = self.client.get("/api/blog/posts/nonexistent")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_related_posts(self):
|
||||
r = self.client.get(f"/api/blog/posts/{self.post.slug}/related")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIsInstance(r.json(), list)
|
||||
|
||||
def test_draft_post_not_listed(self):
|
||||
BlogPost.objects.create(
|
||||
title="Draft", content="x", status="draft", author=self.user
|
||||
)
|
||||
r = self.client.get("/api/blog/posts")
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_private_post_not_listed(self):
|
||||
BlogPost.objects.create(
|
||||
title="Private",
|
||||
content="x",
|
||||
status="published",
|
||||
is_private=True,
|
||||
author=self.user,
|
||||
)
|
||||
r = self.client.get("/api/blog/posts")
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_list_tags(self):
|
||||
r = self.client.get("/api/blog/tags")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["name"], "Python")
|
||||
|
||||
|
||||
class PortfolioAPITest(APITestBase):
|
||||
def test_list_projects(self):
|
||||
r = self.client.get("/api/portfolio/projects")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertEqual(len(data), 1)
|
||||
self.assertEqual(data[0]["title"], "Test Project")
|
||||
|
||||
def test_list_projects_tech_filter(self):
|
||||
r = self.client.get("/api/portfolio/projects?tech=django")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_featured_projects(self):
|
||||
r = self.client.get("/api/portfolio/projects/featured")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_get_project(self):
|
||||
r = self.client.get(f"/api/portfolio/projects/{self.project.slug}")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertEqual(data["title"], "Test Project")
|
||||
self.assertEqual(data["technologies"][0]["name"], "Django")
|
||||
|
||||
def test_get_project_not_found(self):
|
||||
r = self.client.get("/api/portfolio/projects/nonexistent")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
def test_related_projects(self):
|
||||
r = self.client.get(f"/api/portfolio/projects/{self.project.slug}/related")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertIsInstance(r.json(), list)
|
||||
|
||||
def test_draft_project_not_listed(self):
|
||||
Project.objects.create(
|
||||
title="Draft Project", tagline="x", description="x", status="draft"
|
||||
)
|
||||
r = self.client.get("/api/portfolio/projects")
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
def test_list_technologies(self):
|
||||
r = self.client.get("/api/portfolio/technologies")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertEqual(len(r.json()), 1)
|
||||
|
||||
|
||||
class ContactAPITest(APITestBase):
|
||||
def test_submit_contact(self):
|
||||
r = self.client.post(
|
||||
"/api/contact/submit",
|
||||
data={
|
||||
"name": "John",
|
||||
"email": "john@example.com",
|
||||
"subject": "Hello",
|
||||
"message": "Test message",
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
data = r.json()
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(ContactMessage.objects.count(), 1)
|
||||
|
||||
def test_submit_contact_invalid(self):
|
||||
r = self.client.post(
|
||||
"/api/contact/submit",
|
||||
data={"name": "John"},
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(r.status_code, 422)
|
||||
|
||||
def test_resume_not_found(self):
|
||||
r = self.client.get("/api/contact/resume")
|
||||
self.assertEqual(r.status_code, 404)
|
||||
|
||||
|
||||
class MediaTest(TestCase):
|
||||
def test_media_route_exists(self):
|
||||
r = self.client.get("/media/nonexistent.png")
|
||||
# Should return 404 (not found) not 502 (bad gateway)
|
||||
self.assertIn(r.status_code, [404, 200])
|
||||
|
||||
|
||||
class RoutingTest(TestCase):
|
||||
def test_api_docs(self):
|
||||
r = self.client.get("/api/docs")
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
def test_sitemap(self):
|
||||
# Sitemap requires django.contrib.sitemaps templates which
|
||||
# are available via APP_DIRS in production but may not resolve
|
||||
# in test environment without the sitemaps app in INSTALLED_APPS
|
||||
try:
|
||||
r = self.client.get("/sitemap.xml")
|
||||
self.assertIn(r.status_code, [200, 500])
|
||||
except Exception:
|
||||
pass # Template not found in test env is OK
|
||||
|
||||
def test_admin_redirects(self):
|
||||
r = self.client.get("/admin/")
|
||||
# Should redirect to language-prefixed login
|
||||
self.assertIn(r.status_code, [301, 302])
|
||||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'apps.blog.apps.BlogConfig'
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django.utils import timezone
|
||||
from django import forms
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
from modeltranslation.admin import TranslationAdmin
|
||||
from .models import Tag, BlogPost, PostView
|
||||
|
||||
|
||||
class BlogPostAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = BlogPost
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'content_pt_br': CKEditor5Widget(config_name='extends'),
|
||||
'content_en': CKEditor5Widget(config_name='extends'),
|
||||
'content': CKEditor5Widget(config_name='extends'),
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Tag)
|
||||
class TagAdmin(TranslationAdmin):
|
||||
list_display = ('name', 'slug', 'post_count')
|
||||
search_fields = ('name',)
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
def post_count(self, obj):
|
||||
return obj.posts.count()
|
||||
post_count.short_description = 'Posts'
|
||||
|
||||
|
||||
@admin.register(BlogPost)
|
||||
class BlogPostAdmin(TranslationAdmin):
|
||||
form = BlogPostAdminForm
|
||||
list_display = ('title', 'status', 'is_private', 'author', 'published_at', 'view_count')
|
||||
list_filter = ('status', 'is_private', 'tags', 'created_at')
|
||||
search_fields = ('title', 'content')
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
filter_horizontal = ('tags',)
|
||||
date_hierarchy = 'created_at'
|
||||
raw_id_fields = ('author',)
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('title', 'slug', 'excerpt', 'content', 'featured_image')
|
||||
}),
|
||||
('Classification', {
|
||||
'fields': ('author', 'tags', 'status', 'is_private')
|
||||
}),
|
||||
('SEO', {
|
||||
'fields': ('meta_description', 'meta_keywords'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Dates', {
|
||||
'fields': ('published_at',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
def view_count(self, obj):
|
||||
return obj.views.count()
|
||||
view_count.short_description = 'Views'
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not obj.author:
|
||||
obj.author = request.user
|
||||
if obj.status == BlogPost.Status.PUBLISHED and not obj.published_at:
|
||||
obj.published_at = timezone.now()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
@admin.register(PostView)
|
||||
class PostViewAdmin(admin.ModelAdmin):
|
||||
list_display = ('post', 'ip_address', 'viewed_at')
|
||||
list_filter = ('viewed_at',)
|
||||
search_fields = ('post__title', 'ip_address')
|
||||
date_hierarchy = 'viewed_at'
|
||||
readonly_fields = ('post', 'ip_address', 'user_agent', 'viewed_at')
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BlogConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.blog'
|
||||
verbose_name = 'Blog'
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
"""Export BlogPost rows to Hugo-ready HTML files with YAML frontmatter.
|
||||
|
||||
Output tree:
|
||||
<out>/pt-br/posts/<slug>.html
|
||||
<out>/en/posts/<slug>.html
|
||||
|
||||
Posts with empty content for a given language are skipped silently.
|
||||
Frontmatter contains: title, date, slug, tags, description, draft.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.blog.models import BlogPost
|
||||
|
||||
|
||||
LANGS = ("pt-br", "en")
|
||||
|
||||
|
||||
def lang_attr(post, field, lang):
|
||||
"""Read django-modeltranslation field, e.g. title_en / content_pt_br."""
|
||||
suffix = lang.replace("-", "_")
|
||||
return getattr(post, f"{field}_{suffix}", None)
|
||||
|
||||
|
||||
def safe_title(value, fallback):
|
||||
cleaned = (value or "").strip()
|
||||
return cleaned or fallback
|
||||
|
||||
|
||||
def build_frontmatter(post, lang):
|
||||
title = safe_title(lang_attr(post, "title", lang), post.title)
|
||||
description = (lang_attr(post, "meta_description", lang) or post.meta_description or "").strip()
|
||||
if not description:
|
||||
excerpt = (lang_attr(post, "excerpt", lang) or post.excerpt or "").strip()
|
||||
description = excerpt[:200]
|
||||
|
||||
date = post.published_at or post.created_at
|
||||
tags = sorted(post.tags.values_list("name", flat=True))
|
||||
|
||||
return {
|
||||
"title": title,
|
||||
"date": date.isoformat() if date else None,
|
||||
"slug": post.slug,
|
||||
"tags": list(tags),
|
||||
"description": description,
|
||||
"draft": post.status != BlogPost.Status.PUBLISHED,
|
||||
}
|
||||
|
||||
|
||||
def _yaml_scalar(value):
|
||||
"""Quote scalars safely; bare-emit None as empty string."""
|
||||
if value is None:
|
||||
return '""'
|
||||
s = str(value)
|
||||
return json.dumps(s, ensure_ascii=False)
|
||||
|
||||
|
||||
def render_file(frontmatter, html_body):
|
||||
lines = ["---"]
|
||||
for key, value in frontmatter.items():
|
||||
if isinstance(value, list):
|
||||
if not value:
|
||||
lines.append(f"{key}: []")
|
||||
else:
|
||||
lines.append(f"{key}:")
|
||||
for item in value:
|
||||
lines.append(f" - {_yaml_scalar(item)}")
|
||||
elif isinstance(value, bool):
|
||||
lines.append(f"{key}: {'true' if value else 'false'}")
|
||||
else:
|
||||
lines.append(f"{key}: {_yaml_scalar(value)}")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n" + html_body + "\n"
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Export published blog posts to Hugo-compatible HTML files."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
default="/tmp/hugo-export",
|
||||
help="Output directory (default: /tmp/hugo-export)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-private",
|
||||
action="store_true",
|
||||
help="Include private posts (default: only public).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-drafts",
|
||||
action="store_true",
|
||||
help="Include drafts (marks them draft:true in frontmatter).",
|
||||
)
|
||||
|
||||
def handle(self, *args, **opts):
|
||||
out_root = Path(opts["out"])
|
||||
if opts["include_drafts"]:
|
||||
qs = BlogPost.objects.all()
|
||||
else:
|
||||
qs = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
|
||||
if not opts["include_private"]:
|
||||
qs = qs.filter(is_private=False)
|
||||
|
||||
written = {lang: 0 for lang in LANGS}
|
||||
skipped = {lang: 0 for lang in LANGS}
|
||||
|
||||
for post in qs.iterator():
|
||||
for lang in LANGS:
|
||||
content = lang_attr(post, "content", lang) or ""
|
||||
if not content.strip():
|
||||
skipped[lang] += 1
|
||||
continue
|
||||
|
||||
fm = build_frontmatter(post, lang)
|
||||
rendered = render_file(fm, content)
|
||||
|
||||
target = out_root / lang / "posts" / f"{post.slug}.html"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(rendered, encoding="utf-8")
|
||||
written[lang] += 1
|
||||
|
||||
for lang in LANGS:
|
||||
self.stdout.write(
|
||||
f"[{lang}] wrote {written[lang]} files, skipped {skipped[lang]} (empty)"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS(f"Done -> {out_root}"))
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('description', models.TextField(blank=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BlogPost',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('excerpt', models.TextField(blank=True, help_text='Short description for listings')),
|
||||
('content', models.TextField(help_text='Markdown content')),
|
||||
('featured_image', models.ImageField(blank=True, null=True, upload_to='blog/images/')),
|
||||
('is_private', models.BooleanField(default=False, help_text='Only visible to owner')),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('published_at', models.DateTimeField(blank=True, null=True)),
|
||||
('meta_description', models.CharField(blank=True, max_length=160)),
|
||||
('meta_keywords', models.CharField(blank=True, max_length=255)),
|
||||
('author', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='blog_posts', to=settings.AUTH_USER_MODEL)),
|
||||
('tags', models.ManyToManyField(blank=True, related_name='posts', to='blog.tag')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-published_at', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PostView',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('viewed_at', models.DateTimeField(auto_now_add=True)),
|
||||
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='views', to='blog.blogpost')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-viewed_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 16:03
|
||||
|
||||
import django_ckeditor_5.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('blog', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='blogpost',
|
||||
name='content',
|
||||
field=django_ckeditor_5.fields.CKEditor5Field(verbose_name='Content'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 16:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('blog', '0002_alter_blogpost_content'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='content_en',
|
||||
field=models.TextField(help_text='HTML content', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='content_pt_br',
|
||||
field=models.TextField(help_text='HTML content', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='excerpt_en',
|
||||
field=models.TextField(blank=True, help_text='Short description for listings', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='excerpt_pt_br',
|
||||
field=models.TextField(blank=True, help_text='Short description for listings', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='meta_description_en',
|
||||
field=models.CharField(blank=True, max_length=160, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='meta_description_pt_br',
|
||||
field=models.CharField(blank=True, max_length=160, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='meta_keywords_en',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='meta_keywords_pt_br',
|
||||
field=models.CharField(blank=True, max_length=255, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='title_en',
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='blogpost',
|
||||
name='title_pt_br',
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='description_en',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='description_pt_br',
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='name_en',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='name_pt_br',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='blogpost',
|
||||
name='content',
|
||||
field=models.TextField(help_text='HTML content'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,100 +0,0 @@
|
|||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
from django.utils.text import slugify
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
class Tag(models.Model):
|
||||
"""Tag for categorizing blog posts."""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(max_length=50, unique=True)
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('blog:tag_detail', kwargs={'slug': self.slug})
|
||||
|
||||
|
||||
class BlogPost(models.Model):
|
||||
"""Blog post with markdown content."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
excerpt = models.TextField(blank=True, help_text='Short description for listings')
|
||||
content = models.TextField(help_text='HTML content')
|
||||
featured_image = models.ImageField(upload_to='blog/images/', blank=True, null=True)
|
||||
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
related_name='blog_posts'
|
||||
)
|
||||
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
|
||||
|
||||
is_private = models.BooleanField(default=False, help_text='Only visible to owner')
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
published_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
# SEO fields
|
||||
meta_description = models.CharField(max_length=160, blank=True)
|
||||
meta_keywords = models.CharField(max_length=255, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-published_at', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
if not self.excerpt and self.content:
|
||||
# Strip HTML tags for excerpt
|
||||
plain_text = strip_tags(self.content)
|
||||
self.excerpt = plain_text[:200] + '...' if len(plain_text) > 200 else plain_text
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('blog:post_detail', kwargs={'slug': self.slug})
|
||||
|
||||
@property
|
||||
def content_html(self):
|
||||
"""Return HTML content from CKEditor."""
|
||||
return self.content
|
||||
|
||||
@property
|
||||
def reading_time(self):
|
||||
"""Estimate reading time in minutes."""
|
||||
plain_text = strip_tags(self.content)
|
||||
word_count = len(plain_text.split())
|
||||
return max(1, round(word_count / 200))
|
||||
|
||||
|
||||
class PostView(models.Model):
|
||||
"""Track post views for analytics."""
|
||||
post = models.ForeignKey(BlogPost, on_delete=models.CASCADE, related_name='views')
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
viewed_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-viewed_at']
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
from django.contrib.sitemaps import Sitemap
|
||||
from .models import BlogPost
|
||||
|
||||
|
||||
class BlogSitemap(Sitemap):
|
||||
changefreq = 'weekly'
|
||||
priority = 0.8
|
||||
|
||||
def items(self):
|
||||
return BlogPost.objects.filter(
|
||||
status=BlogPost.Status.PUBLISHED,
|
||||
is_private=False
|
||||
)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestBlogViews:
|
||||
def test_home_returns_200(self):
|
||||
client = Client()
|
||||
response = client.get(reverse("blog:home"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_post_list_returns_200(self):
|
||||
client = Client()
|
||||
response = client.get(reverse("blog:post_list"))
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_rss_feed_returns_200(self):
|
||||
client = Client()
|
||||
response = client.get(reverse("blog:feed"))
|
||||
assert response.status_code == 200
|
||||
assert "application/rss+xml" in response["Content-Type"]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestHealthEndpoint:
|
||||
def test_health_returns_200(self):
|
||||
client = Client()
|
||||
response = client.get("/health/")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["db"] is True
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
from modeltranslation.translator import translator, TranslationOptions
|
||||
from .models import BlogPost, Tag
|
||||
|
||||
|
||||
class BlogPostTranslationOptions(TranslationOptions):
|
||||
fields = ('title', 'excerpt', 'content', 'meta_description', 'meta_keywords')
|
||||
|
||||
|
||||
class TagTranslationOptions(TranslationOptions):
|
||||
fields = ('name', 'description')
|
||||
|
||||
|
||||
translator.register(BlogPost, BlogPostTranslationOptions)
|
||||
translator.register(Tag, TagTranslationOptions)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'blog'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.home, name='home'),
|
||||
path('blog/', views.PostListView.as_view(), name='post_list'),
|
||||
path('blog/<slug:slug>/', views.PostDetailView.as_view(), name='post_detail'),
|
||||
path('blog/tag/<slug:slug>/', views.TagDetailView.as_view(), name='tag_detail'),
|
||||
path('feed/', views.LatestPostsFeed(), name='feed'),
|
||||
]
|
||||
|
|
@ -1,147 +0,0 @@
|
|||
from django.shortcuts import render, get_object_or_404
|
||||
from django.views.generic import ListView, DetailView
|
||||
from django.db.models import Q
|
||||
from django.contrib.syndication.views import Feed
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import BlogPost, Tag, PostView
|
||||
|
||||
|
||||
class PostListView(ListView):
|
||||
model = BlogPost
|
||||
template_name = 'blog/post_list.html'
|
||||
context_object_name = 'posts'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
|
||||
|
||||
# Filter private posts unless user is owner
|
||||
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
|
||||
queryset = queryset.filter(is_private=False)
|
||||
|
||||
# Search functionality
|
||||
search = self.request.GET.get('search')
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search) |
|
||||
Q(content__icontains=search) |
|
||||
Q(tags__name__icontains=search)
|
||||
).distinct()
|
||||
|
||||
# Tag filter
|
||||
tag = self.request.GET.get('tag')
|
||||
if tag:
|
||||
queryset = queryset.filter(tags__slug=tag)
|
||||
|
||||
return queryset.select_related('author').prefetch_related('tags')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['tags'] = Tag.objects.all()
|
||||
context['search'] = self.request.GET.get('search', '')
|
||||
context['active_tag'] = self.request.GET.get('tag', '')
|
||||
return context
|
||||
|
||||
|
||||
class PostDetailView(DetailView):
|
||||
model = BlogPost
|
||||
template_name = 'blog/post_detail.html'
|
||||
context_object_name = 'post'
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = BlogPost.objects.filter(status=BlogPost.Status.PUBLISHED)
|
||||
|
||||
# Filter private posts unless user is owner
|
||||
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
|
||||
queryset = queryset.filter(is_private=False)
|
||||
|
||||
return queryset.select_related('author').prefetch_related('tags')
|
||||
|
||||
def get_object(self, queryset=None):
|
||||
obj = super().get_object(queryset)
|
||||
# Track view
|
||||
PostView.objects.create(
|
||||
post=obj,
|
||||
ip_address=self.get_client_ip(),
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', '')[:500]
|
||||
)
|
||||
return obj
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(',')[0].strip()
|
||||
return self.request.META.get('REMOTE_ADDR')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Get related posts by tags
|
||||
post_tags = self.object.tags.all()
|
||||
context['related_posts'] = BlogPost.objects.filter(
|
||||
tags__in=post_tags,
|
||||
status=BlogPost.Status.PUBLISHED,
|
||||
is_private=False
|
||||
).exclude(pk=self.object.pk).distinct()[:3]
|
||||
return context
|
||||
|
||||
|
||||
class TagDetailView(ListView):
|
||||
model = BlogPost
|
||||
template_name = 'blog/tag_detail.html'
|
||||
context_object_name = 'posts'
|
||||
paginate_by = 10
|
||||
|
||||
def get_queryset(self):
|
||||
self.tag = get_object_or_404(Tag, slug=self.kwargs['slug'])
|
||||
queryset = BlogPost.objects.filter(
|
||||
tags=self.tag,
|
||||
status=BlogPost.Status.PUBLISHED
|
||||
)
|
||||
|
||||
if not self.request.user.is_authenticated or not getattr(self.request.user, 'is_owner', False):
|
||||
queryset = queryset.filter(is_private=False)
|
||||
|
||||
return queryset.select_related('author').prefetch_related('tags')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['tag'] = self.tag
|
||||
context['tags'] = Tag.objects.all()
|
||||
return context
|
||||
|
||||
|
||||
class LatestPostsFeed(Feed):
|
||||
title = "richardnixon.dev - Blog"
|
||||
link = "/blog/"
|
||||
description = "Latest posts from richardnixon.dev"
|
||||
|
||||
def items(self):
|
||||
return BlogPost.objects.filter(
|
||||
status=BlogPost.Status.PUBLISHED,
|
||||
is_private=False
|
||||
).order_by('-published_at')[:10]
|
||||
|
||||
def item_title(self, item):
|
||||
return item.title
|
||||
|
||||
def item_description(self, item):
|
||||
return item.excerpt
|
||||
|
||||
def item_link(self, item):
|
||||
return item.get_absolute_url()
|
||||
|
||||
def item_pubdate(self, item):
|
||||
return item.published_at
|
||||
|
||||
|
||||
def home(request):
|
||||
"""Homepage view."""
|
||||
recent_posts = BlogPost.objects.filter(
|
||||
status=BlogPost.Status.PUBLISHED,
|
||||
is_private=False
|
||||
).select_related('author').prefetch_related('tags')[:5]
|
||||
|
||||
return render(request, 'blog/home.html', {
|
||||
'recent_posts': recent_posts,
|
||||
})
|
||||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'apps.contact.apps.ContactConfig'
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from .models import ContactMessage, Resume
|
||||
|
||||
|
||||
@admin.register(ContactMessage)
|
||||
class ContactMessageAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'email', 'subject', 'status', 'created_at', 'is_spam')
|
||||
list_filter = ('status', 'created_at')
|
||||
search_fields = ('name', 'email', 'subject', 'message')
|
||||
readonly_fields = ('ip_address', 'user_agent', 'referrer', 'honeypot', 'submission_time', 'created_at')
|
||||
date_hierarchy = 'created_at'
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('name', 'email', 'subject', 'message', 'status')
|
||||
}),
|
||||
('Metadata', {
|
||||
'fields': ('ip_address', 'user_agent', 'referrer', 'created_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
('Spam Detection', {
|
||||
'fields': ('honeypot', 'submission_time'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['mark_as_spam', 'mark_as_read', 'mark_as_archived']
|
||||
|
||||
def is_spam(self, obj):
|
||||
return obj.is_likely_spam
|
||||
is_spam.boolean = True
|
||||
is_spam.short_description = 'Spam?'
|
||||
|
||||
@admin.action(description='Mark selected as spam')
|
||||
def mark_as_spam(self, request, queryset):
|
||||
queryset.update(status=ContactMessage.Status.SPAM)
|
||||
|
||||
@admin.action(description='Mark selected as read')
|
||||
def mark_as_read(self, request, queryset):
|
||||
queryset.update(status=ContactMessage.Status.READ)
|
||||
|
||||
@admin.action(description='Archive selected')
|
||||
def mark_as_archived(self, request, queryset):
|
||||
queryset.update(status=ContactMessage.Status.ARCHIVED)
|
||||
|
||||
|
||||
@admin.register(Resume)
|
||||
class ResumeAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'is_active', 'download_count', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ContactConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.contact'
|
||||
verbose_name = 'Contact'
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django_recaptcha.fields import ReCaptchaField
|
||||
from django_recaptcha.widgets import ReCaptchaV3
|
||||
from .models import ContactMessage
|
||||
|
||||
|
||||
class ContactForm(forms.ModelForm):
|
||||
"""Contact form with spam protection."""
|
||||
|
||||
# Honeypot field - should be hidden and left empty
|
||||
website = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={
|
||||
'class': 'hidden',
|
||||
'tabindex': '-1',
|
||||
'autocomplete': 'off'
|
||||
})
|
||||
)
|
||||
|
||||
# Hidden timestamp field for timing check
|
||||
form_time = forms.FloatField(widget=forms.HiddenInput(), required=False)
|
||||
|
||||
# reCAPTCHA v3 field (invisible)
|
||||
captcha = ReCaptchaField(widget=ReCaptchaV3())
|
||||
|
||||
class Meta:
|
||||
model = ContactMessage
|
||||
fields = ['name', 'email', 'subject', 'message']
|
||||
widgets = {
|
||||
'name': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
|
||||
'placeholder': 'Your name'
|
||||
}),
|
||||
'email': forms.EmailInput(attrs={
|
||||
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
|
||||
'placeholder': 'your@email.com'
|
||||
}),
|
||||
'subject': forms.TextInput(attrs={
|
||||
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
|
||||
'placeholder': 'Subject'
|
||||
}),
|
||||
'message': forms.Textarea(attrs={
|
||||
'class': 'w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent',
|
||||
'placeholder': 'Your message...',
|
||||
'rows': 5
|
||||
}),
|
||||
}
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
|
||||
# Check honeypot
|
||||
if cleaned_data.get('website'):
|
||||
raise forms.ValidationError('Spam detected.')
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def save(self, commit=True, **kwargs):
|
||||
instance = super().save(commit=False)
|
||||
|
||||
# Store honeypot value
|
||||
instance.honeypot = self.cleaned_data.get('website', '')
|
||||
|
||||
# Calculate submission time
|
||||
import time
|
||||
form_time = self.cleaned_data.get('form_time', 0)
|
||||
if form_time:
|
||||
instance.submission_time = time.time() - form_time
|
||||
|
||||
# Store additional metadata from kwargs
|
||||
instance.ip_address = kwargs.get('ip_address')
|
||||
instance.user_agent = kwargs.get('user_agent', '')[:500]
|
||||
instance.referrer = kwargs.get('referrer', '')[:200]
|
||||
|
||||
# Auto-mark as spam if detected
|
||||
if instance.is_likely_spam:
|
||||
instance.status = ContactMessage.Status.SPAM
|
||||
|
||||
if commit:
|
||||
instance.save()
|
||||
|
||||
return instance
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 00:02
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ContactMessage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('email', models.EmailField(max_length=254)),
|
||||
('subject', models.CharField(max_length=200)),
|
||||
('message', models.TextField()),
|
||||
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
|
||||
('user_agent', models.TextField(blank=True)),
|
||||
('referrer', models.URLField(blank=True)),
|
||||
('honeypot', models.CharField(blank=True, help_text='Hidden field for spam detection', max_length=200)),
|
||||
('submission_time', models.FloatField(default=0, help_text='Time taken to submit form in seconds')),
|
||||
('status', models.CharField(choices=[('new', 'New'), ('read', 'Read'), ('replied', 'Replied'), ('spam', 'Spam'), ('archived', 'Archived')], default='new', max_length=20)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Contact Message',
|
||||
'verbose_name_plural': 'Contact Messages',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Resume',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(default='Resume', max_length=100)),
|
||||
('file', models.FileField(upload_to='resumes/')),
|
||||
('is_active', models.BooleanField(default=True)),
|
||||
('download_count', models.IntegerField(default=0)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
from django.db import models
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class ContactMessage(models.Model):
|
||||
"""Contact form submission."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
NEW = 'new', 'New'
|
||||
READ = 'read', 'Read'
|
||||
REPLIED = 'replied', 'Replied'
|
||||
SPAM = 'spam', 'Spam'
|
||||
ARCHIVED = 'archived', 'Archived'
|
||||
|
||||
name = models.CharField(max_length=100)
|
||||
email = models.EmailField()
|
||||
subject = models.CharField(max_length=200)
|
||||
message = models.TextField()
|
||||
|
||||
# Metadata
|
||||
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
||||
user_agent = models.TextField(blank=True)
|
||||
referrer = models.URLField(blank=True)
|
||||
|
||||
# Spam detection
|
||||
honeypot = models.CharField(max_length=200, blank=True, help_text='Hidden field for spam detection')
|
||||
submission_time = models.FloatField(default=0, help_text='Time taken to submit form in seconds')
|
||||
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.NEW)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
verbose_name = 'Contact Message'
|
||||
verbose_name_plural = 'Contact Messages'
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} - {self.subject}"
|
||||
|
||||
@property
|
||||
def is_likely_spam(self):
|
||||
"""Basic spam detection heuristics."""
|
||||
# Honeypot filled
|
||||
if self.honeypot:
|
||||
return True
|
||||
# Form submitted too quickly (less than 3 seconds)
|
||||
if self.submission_time > 0 and self.submission_time < 3:
|
||||
return True
|
||||
# Check for common spam patterns
|
||||
spam_keywords = ['viagra', 'casino', 'crypto', 'bitcoin', 'lottery', 'prize']
|
||||
content = f"{self.subject} {self.message}".lower()
|
||||
if any(keyword in content for keyword in spam_keywords):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class Resume(models.Model):
|
||||
"""Uploaded resume/CV for download."""
|
||||
title = models.CharField(max_length=100, default='Resume')
|
||||
file = models.FileField(upload_to='resumes/')
|
||||
is_active = models.BooleanField(default=True)
|
||||
download_count = models.IntegerField(default=0)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def increment_download(self):
|
||||
self.download_count += 1
|
||||
self.save(update_fields=['download_count'])
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestContactViews:
|
||||
def test_contact_form_get_returns_200(self):
|
||||
client = Client()
|
||||
response = client.get(reverse("contact:contact"))
|
||||
assert response.status_code == 200
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'contact'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.ContactView.as_view(), name='contact'),
|
||||
path('resume/', views.ResumeDownloadView.as_view(), name='resume_download'),
|
||||
]
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import time
|
||||
from django.shortcuts import render, redirect
|
||||
from django.views.generic import FormView, View
|
||||
from django.http import FileResponse, Http404
|
||||
from django.contrib import messages
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from .forms import ContactForm
|
||||
from .models import Resume
|
||||
|
||||
|
||||
class ContactView(FormView):
|
||||
template_name = 'contact/contact.html'
|
||||
form_class = ContactForm
|
||||
success_url = reverse_lazy('contact:contact')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['form_time'] = time.time()
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
initial = super().get_initial()
|
||||
initial['form_time'] = time.time()
|
||||
return initial
|
||||
|
||||
def form_valid(self, form):
|
||||
form.save(
|
||||
ip_address=self.get_client_ip(),
|
||||
user_agent=self.request.META.get('HTTP_USER_AGENT', ''),
|
||||
referrer=self.request.META.get('HTTP_REFERER', '')
|
||||
)
|
||||
messages.success(self.request, 'Thank you for your message! I will get back to you soon.')
|
||||
return super().form_valid(form)
|
||||
|
||||
def form_invalid(self, form):
|
||||
messages.error(self.request, 'Please correct the errors below.')
|
||||
return super().form_invalid(form)
|
||||
|
||||
def get_client_ip(self):
|
||||
x_forwarded_for = self.request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
return x_forwarded_for.split(',')[0].strip()
|
||||
return self.request.META.get('REMOTE_ADDR')
|
||||
|
||||
|
||||
class ResumeDownloadView(View):
|
||||
def get(self, request):
|
||||
resume = Resume.objects.filter(is_active=True).first()
|
||||
if not resume:
|
||||
raise Http404("Resume not found")
|
||||
|
||||
resume.increment_download()
|
||||
|
||||
response = FileResponse(
|
||||
resume.file.open('rb'),
|
||||
as_attachment=True,
|
||||
filename=f"richard_nixon_resume.pdf"
|
||||
)
|
||||
return response
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import logging
|
||||
|
||||
from django.db import connection
|
||||
from django.core.cache import cache
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.cache import never_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@never_cache
|
||||
def health(request):
|
||||
db_ok = False
|
||||
cache_ok = False
|
||||
|
||||
try:
|
||||
connection.ensure_connection()
|
||||
db_ok = True
|
||||
except Exception:
|
||||
logger.warning("Health check: database connection failed")
|
||||
|
||||
try:
|
||||
cache.set("_health_check", "1", timeout=5)
|
||||
cache_ok = cache.get("_health_check") == "1"
|
||||
except Exception:
|
||||
logger.warning("Health check: cache connection failed")
|
||||
|
||||
status_code = 200 if (db_ok and cache_ok) else 503
|
||||
return JsonResponse(
|
||||
{"status": "ok" if status_code == 200 else "degraded", "db": db_ok, "cache": cache_ok},
|
||||
status=status_code,
|
||||
)
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('tagline', models.CharField(help_text='Short one-line description', max_length=200)),
|
||||
('description', models.TextField(help_text='Full project description (Markdown)')),
|
||||
('featured_image', models.ImageField(blank=True, null=True, upload_to='portfolio/images/')),
|
||||
('thumbnail', models.ImageField(blank=True, null=True, upload_to='portfolio/thumbnails/')),
|
||||
('live_url', models.URLField(blank=True, help_text='Live demo URL')),
|
||||
('github_url', models.URLField(blank=True, help_text='GitHub repository URL')),
|
||||
('documentation_url', models.URLField(blank=True, help_text='Documentation URL')),
|
||||
('is_featured', models.BooleanField(default=False, help_text='Show on homepage')),
|
||||
('order', models.IntegerField(default=0, help_text='Display order (lower = first)')),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=20)),
|
||||
('start_date', models.DateField(blank=True, null=True)),
|
||||
('end_date', models.DateField(blank=True, help_text='Leave blank if ongoing', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Technology',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('icon', models.CharField(blank=True, help_text='Icon class (e.g., devicon-python-plain)', max_length=50)),
|
||||
('color', models.CharField(default='#6366f1', help_text='Hex color code', max_length=7)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Technologies',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='portfolio/gallery/')),
|
||||
('caption', models.CharField(blank=True, max_length=200)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='portfolio.project')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='technologies',
|
||||
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.technology'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1 +0,0 @@
|
|||
default_app_config = 'apps.portfolio.apps.PortfolioConfig'
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
from django.contrib import admin
|
||||
from django import forms
|
||||
from django_ckeditor_5.widgets import CKEditor5Widget
|
||||
from modeltranslation.admin import TranslationAdmin
|
||||
from .models import Technology, Project, ProjectImage
|
||||
|
||||
|
||||
class ProjectAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'description_pt_br': CKEditor5Widget(config_name='default'),
|
||||
'description_en': CKEditor5Widget(config_name='default'),
|
||||
'description': CKEditor5Widget(config_name='default'),
|
||||
}
|
||||
|
||||
|
||||
@admin.register(Technology)
|
||||
class TechnologyAdmin(TranslationAdmin):
|
||||
list_display = ('name', 'slug', 'icon', 'color', 'project_count')
|
||||
search_fields = ('name',)
|
||||
prepopulated_fields = {'slug': ('name',)}
|
||||
|
||||
def project_count(self, obj):
|
||||
return obj.projects.count()
|
||||
project_count.short_description = 'Projects'
|
||||
|
||||
|
||||
class ProjectImageInline(admin.TabularInline):
|
||||
model = ProjectImage
|
||||
extra = 1
|
||||
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(TranslationAdmin):
|
||||
form = ProjectAdminForm
|
||||
list_display = ('title', 'status', 'is_featured', 'order', 'start_date', 'end_date')
|
||||
list_filter = ('status', 'is_featured', 'technologies')
|
||||
search_fields = ('title', 'description', 'tagline')
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
filter_horizontal = ('technologies',)
|
||||
inlines = [ProjectImageInline]
|
||||
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('title', 'slug', 'tagline', 'description')
|
||||
}),
|
||||
('Media', {
|
||||
'fields': ('featured_image', 'thumbnail')
|
||||
}),
|
||||
('Technologies', {
|
||||
'fields': ('technologies',)
|
||||
}),
|
||||
('Links', {
|
||||
'fields': ('live_url', 'github_url', 'documentation_url')
|
||||
}),
|
||||
('Display', {
|
||||
'fields': ('is_featured', 'order', 'status')
|
||||
}),
|
||||
('Dates', {
|
||||
'fields': ('start_date', 'end_date'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class PortfolioConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.portfolio'
|
||||
verbose_name = 'Portfolio'
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200)),
|
||||
('slug', models.SlugField(max_length=200, unique=True)),
|
||||
('tagline', models.CharField(help_text='Short one-line description', max_length=200)),
|
||||
('description', models.TextField(help_text='Full project description (Markdown)')),
|
||||
('featured_image', models.ImageField(blank=True, null=True, upload_to='portfolio/images/')),
|
||||
('thumbnail', models.ImageField(blank=True, null=True, upload_to='portfolio/thumbnails/')),
|
||||
('live_url', models.URLField(blank=True, help_text='Live demo URL')),
|
||||
('github_url', models.URLField(blank=True, help_text='GitHub repository URL')),
|
||||
('documentation_url', models.URLField(blank=True, help_text='Documentation URL')),
|
||||
('is_featured', models.BooleanField(default=False, help_text='Show on homepage')),
|
||||
('order', models.IntegerField(default=0, help_text='Display order (lower = first)')),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('archived', 'Archived')], default='draft', max_length=20)),
|
||||
('start_date', models.DateField(blank=True, null=True)),
|
||||
('end_date', models.DateField(blank=True, help_text='Leave blank if ongoing', null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Technology',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('icon', models.CharField(blank=True, help_text='Icon class (e.g., devicon-python-plain)', max_length=50)),
|
||||
('color', models.CharField(default='#6366f1', help_text='Hex color code', max_length=7)),
|
||||
],
|
||||
options={
|
||||
'verbose_name_plural': 'Technologies',
|
||||
'ordering': ['name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectImage',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('image', models.ImageField(upload_to='portfolio/gallery/')),
|
||||
('caption', models.CharField(blank=True, max_length=200)),
|
||||
('order', models.IntegerField(default=0)),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='portfolio.project')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='technologies',
|
||||
field=models.ManyToManyField(blank=True, related_name='projects', to='portfolio.technology'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 16:03
|
||||
|
||||
import django_ckeditor_5.fields
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portfolio', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='description',
|
||||
field=django_ckeditor_5.fields.CKEditor5Field(verbose_name='Description'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
# Generated by Django 5.2.10 on 2026-01-25 16:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('portfolio', '0002_alter_project_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='description_en',
|
||||
field=models.TextField(help_text='HTML content', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='description_pt_br',
|
||||
field=models.TextField(help_text='HTML content', null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='tagline_en',
|
||||
field=models.CharField(help_text='Short one-line description', max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='tagline_pt_br',
|
||||
field=models.CharField(help_text='Short one-line description', max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='title_en',
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='title_pt_br',
|
||||
field=models.CharField(max_length=200, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='technology',
|
||||
name='name_en',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='technology',
|
||||
name='name_pt_br',
|
||||
field=models.CharField(max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='description',
|
||||
field=models.TextField(help_text='HTML content'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
|
||||
|
||||
class Technology(models.Model):
|
||||
"""Technology/skill used in projects."""
|
||||
name = models.CharField(max_length=50, unique=True)
|
||||
slug = models.SlugField(max_length=50, unique=True)
|
||||
icon = models.CharField(max_length=50, blank=True, help_text='Icon class (e.g., devicon-python-plain)')
|
||||
color = models.CharField(max_length=7, default='#6366f1', help_text='Hex color code')
|
||||
|
||||
class Meta:
|
||||
verbose_name_plural = 'Technologies'
|
||||
ordering = ['name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.name)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
"""Portfolio project."""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
DRAFT = 'draft', 'Draft'
|
||||
PUBLISHED = 'published', 'Published'
|
||||
ARCHIVED = 'archived', 'Archived'
|
||||
|
||||
title = models.CharField(max_length=200)
|
||||
slug = models.SlugField(max_length=200, unique=True)
|
||||
tagline = models.CharField(max_length=200, help_text='Short one-line description')
|
||||
description = models.TextField(help_text='HTML content')
|
||||
featured_image = models.ImageField(upload_to='portfolio/images/', blank=True, null=True)
|
||||
thumbnail = models.ImageField(upload_to='portfolio/thumbnails/', blank=True, null=True)
|
||||
|
||||
technologies = models.ManyToManyField(Technology, blank=True, related_name='projects')
|
||||
|
||||
# Links
|
||||
live_url = models.URLField(blank=True, help_text='Live demo URL')
|
||||
github_url = models.URLField(blank=True, help_text='GitHub repository URL')
|
||||
documentation_url = models.URLField(blank=True, help_text='Documentation URL')
|
||||
|
||||
# Display settings
|
||||
is_featured = models.BooleanField(default=False, help_text='Show on homepage')
|
||||
order = models.IntegerField(default=0, help_text='Display order (lower = first)')
|
||||
status = models.CharField(max_length=20, choices=Status.choices, default=Status.DRAFT)
|
||||
|
||||
# Dates
|
||||
start_date = models.DateField(null=True, blank=True)
|
||||
end_date = models.DateField(null=True, blank=True, help_text='Leave blank if ongoing')
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('portfolio:project_detail', kwargs={'slug': self.slug})
|
||||
|
||||
@property
|
||||
def is_ongoing(self):
|
||||
return self.end_date is None
|
||||
|
||||
|
||||
class ProjectImage(models.Model):
|
||||
"""Additional images for a project."""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='images')
|
||||
image = models.ImageField(upload_to='portfolio/gallery/')
|
||||
caption = models.CharField(max_length=200, blank=True)
|
||||
order = models.IntegerField(default=0)
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.project.title} - Image {self.order}"
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
from django.contrib.sitemaps import Sitemap
|
||||
from .models import Project
|
||||
|
||||
|
||||
class PortfolioSitemap(Sitemap):
|
||||
changefreq = 'monthly'
|
||||
priority = 0.7
|
||||
|
||||
def items(self):
|
||||
return Project.objects.filter(status=Project.Status.PUBLISHED)
|
||||
|
||||
def lastmod(self, obj):
|
||||
return obj.updated_at
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
from modeltranslation.translator import translator, TranslationOptions
|
||||
from .models import Project, Technology
|
||||
|
||||
|
||||
class ProjectTranslationOptions(TranslationOptions):
|
||||
fields = ('title', 'tagline', 'description')
|
||||
|
||||
|
||||
class TechnologyTranslationOptions(TranslationOptions):
|
||||
fields = ('name',)
|
||||
|
||||
|
||||
translator.register(Project, ProjectTranslationOptions)
|
||||
translator.register(Technology, TechnologyTranslationOptions)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
app_name = 'portfolio'
|
||||
|
||||
urlpatterns = [
|
||||
path('', views.ProjectListView.as_view(), name='project_list'),
|
||||
path('<slug:slug>/', views.ProjectDetailView.as_view(), name='project_detail'),
|
||||
]
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
from django.views.generic import ListView, DetailView
|
||||
from .models import Project, Technology
|
||||
|
||||
|
||||
class ProjectListView(ListView):
|
||||
model = Project
|
||||
template_name = 'portfolio/project_list.html'
|
||||
context_object_name = 'projects'
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Project.objects.filter(status=Project.Status.PUBLISHED)
|
||||
|
||||
# Filter by technology
|
||||
tech = self.request.GET.get('tech')
|
||||
if tech:
|
||||
queryset = queryset.filter(technologies__slug=tech)
|
||||
|
||||
return queryset.prefetch_related('technologies')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['technologies'] = Technology.objects.all()
|
||||
context['active_tech'] = self.request.GET.get('tech', '')
|
||||
context['featured_projects'] = Project.objects.filter(
|
||||
status=Project.Status.PUBLISHED,
|
||||
is_featured=True
|
||||
).prefetch_related('technologies')[:3]
|
||||
return context
|
||||
|
||||
|
||||
class ProjectDetailView(DetailView):
|
||||
model = Project
|
||||
template_name = 'portfolio/project_detail.html'
|
||||
context_object_name = 'project'
|
||||
|
||||
def get_queryset(self):
|
||||
return Project.objects.filter(
|
||||
status=Project.Status.PUBLISHED
|
||||
).prefetch_related('technologies', 'images')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
# Get related projects by technology
|
||||
project_techs = self.object.technologies.all()
|
||||
context['related_projects'] = Project.objects.filter(
|
||||
technologies__in=project_techs,
|
||||
status=Project.Status.PUBLISHED
|
||||
).exclude(pk=self.object.pk).distinct()[:3]
|
||||
return context
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# Config package
|
||||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ('celery_app',)
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
"""
|
||||
ASGI config for richardnixon.dev platform.
|
||||
"""
|
||||
import os
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
|
||||
|
||||
application = get_asgi_application()
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
"""
|
||||
Celery configuration for richardnixon.dev platform.
|
||||
"""
|
||||
import os
|
||||
from celery import Celery
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
|
||||
|
||||
app = Celery('platform')
|
||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||
app.autodiscover_tasks()
|
||||
|
||||
|
||||
@app.task(bind=True, ignore_result=True)
|
||||
def debug_task(self):
|
||||
print(f'Request: {self.request!r}')
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
from django.conf import settings
|
||||
|
||||
|
||||
def umami(request):
|
||||
"""Add Umami Analytics website ID to template context."""
|
||||
return {
|
||||
'UMAMI_WEBSITE_ID': getattr(settings, 'UMAMI_WEBSITE_ID', ''),
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Settings package
|
||||
|
|
@ -1,335 +0,0 @@
|
|||
"""
|
||||
Base Django settings for richardnixon.dev platform.
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Build paths inside the project
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get('DJANGO_SECRET_KEY', 'insecure-dev-key-change-in-production')
|
||||
|
||||
# Application definition
|
||||
INSTALLED_APPS = [
|
||||
'modeltranslation', # Must be before admin
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.humanize',
|
||||
|
||||
# Third-party apps
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.socialaccount',
|
||||
'allauth.socialaccount.providers.google',
|
||||
'allauth.socialaccount.providers.github',
|
||||
'django_celery_beat',
|
||||
'django_celery_results',
|
||||
'django_ckeditor_5',
|
||||
|
||||
# Two-factor authentication
|
||||
'django_otp',
|
||||
'django_otp.plugins.otp_static',
|
||||
'django_otp.plugins.otp_totp',
|
||||
'two_factor',
|
||||
'two_factor.plugins.phonenumber',
|
||||
|
||||
# reCAPTCHA
|
||||
'django_recaptcha',
|
||||
|
||||
# CORS
|
||||
'corsheaders',
|
||||
|
||||
# Local apps
|
||||
'apps.accounts',
|
||||
'apps.blog',
|
||||
'apps.portfolio',
|
||||
'apps.contact',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.locale.LocaleMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django_otp.middleware.OTPMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'allauth.account.middleware.AccountMiddleware',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR / 'templates'],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
'django.template.context_processors.i18n',
|
||||
'config.context_processors.umami',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
# Database
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': os.environ.get('POSTGRES_DB', 'platform'),
|
||||
'USER': os.environ.get('POSTGRES_USER', 'platform'),
|
||||
'PASSWORD': os.environ.get('POSTGRES_PASSWORD', 'platform'),
|
||||
'HOST': os.environ.get('POSTGRES_HOST', 'platform-db'),
|
||||
'PORT': os.environ.get('POSTGRES_PORT', '5432'),
|
||||
}
|
||||
}
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
||||
'LOCATION': os.environ.get('REDIS_URL', 'redis://platform-redis:6379/0'),
|
||||
}
|
||||
}
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
|
||||
SESSION_CACHE_ALIAS = 'default'
|
||||
|
||||
# Password validation
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
|
||||
{'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
|
||||
]
|
||||
|
||||
# Custom user model
|
||||
AUTH_USER_MODEL = 'accounts.User'
|
||||
|
||||
# Internationalization
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
LANGUAGE_CODE = 'pt-br'
|
||||
TIME_ZONE = 'America/Sao_Paulo'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
LANGUAGES = [
|
||||
('pt-br', _('Português (Brasil)')),
|
||||
('en', _('English')),
|
||||
]
|
||||
|
||||
LOCALE_PATHS = [
|
||||
BASE_DIR / 'locale',
|
||||
]
|
||||
|
||||
# Static files
|
||||
STATIC_URL = '/static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||
STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage'
|
||||
|
||||
# Media files
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Default primary key field type
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
# Sites framework
|
||||
SITE_ID = 1
|
||||
|
||||
# Allauth settings
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
]
|
||||
|
||||
ACCOUNT_LOGIN_ON_GET = True
|
||||
ACCOUNT_LOGOUT_ON_GET = True
|
||||
ACCOUNT_LOGIN_METHODS = {'email'}
|
||||
ACCOUNT_SIGNUP_FIELDS = ['email*', 'password1*']
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'optional'
|
||||
ACCOUNT_UNIQUE_EMAIL = True
|
||||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = True
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = True
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
'google': {
|
||||
'SCOPE': ['profile', 'email'],
|
||||
'AUTH_PARAMS': {'access_type': 'online'},
|
||||
},
|
||||
'github': {
|
||||
'SCOPE': ['user:email'],
|
||||
},
|
||||
}
|
||||
|
||||
LOGIN_REDIRECT_URL = '/'
|
||||
LOGOUT_REDIRECT_URL = '/'
|
||||
LOGIN_URL = 'two_factor:login'
|
||||
|
||||
# Two-factor authentication settings
|
||||
TWO_FACTOR_PATCH_ADMIN = True
|
||||
TWO_FACTOR_LOGIN_TIMEOUT = 600 # 10 minutes
|
||||
TWO_FACTOR_CALL_GATEWAY = None
|
||||
TWO_FACTOR_SMS_GATEWAY = None
|
||||
|
||||
# Celery settings
|
||||
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://platform-redis:6379/1')
|
||||
CELERY_RESULT_BACKEND = 'django-db'
|
||||
CELERY_CACHE_BACKEND = 'default'
|
||||
CELERY_ACCEPT_CONTENT = ['json']
|
||||
CELERY_TASK_SERIALIZER = 'json'
|
||||
CELERY_RESULT_SERIALIZER = 'json'
|
||||
CELERY_TIMEZONE = TIME_ZONE
|
||||
CELERY_BEAT_SCHEDULER = 'django_celery_beat.schedulers:DatabaseScheduler'
|
||||
|
||||
# Email settings (default to console for development)
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Logging
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# Umami Analytics
|
||||
# Get Website ID from Umami dashboard after setup
|
||||
UMAMI_WEBSITE_ID = os.environ.get('UMAMI_WEBSITE_ID', '')
|
||||
|
||||
# CKEditor 5 settings
|
||||
CKEDITOR_5_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||
CKEDITOR_5_UPLOAD_PATH = 'ckeditor/'
|
||||
|
||||
CKEDITOR_5_CONFIGS = {
|
||||
'default': {
|
||||
'toolbar': [
|
||||
'heading', '|',
|
||||
'bold', 'italic', 'underline', 'strikethrough', '|',
|
||||
'link', 'blockQuote', 'code', 'codeBlock', '|',
|
||||
'bulletedList', 'numberedList', 'todoList', '|',
|
||||
'outdent', 'indent', '|',
|
||||
'imageUpload', 'insertTable', 'mediaEmbed', '|',
|
||||
'undo', 'redo', '|',
|
||||
'sourceEditing',
|
||||
],
|
||||
'image': {
|
||||
'toolbar': [
|
||||
'imageTextAlternative', 'toggleImageCaption', '|',
|
||||
'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', '|',
|
||||
'resizeImage',
|
||||
],
|
||||
'resizeOptions': [
|
||||
{'name': 'resizeImage:original', 'label': 'Original', 'value': None},
|
||||
{'name': 'resizeImage:25', 'label': '25%', 'value': '25'},
|
||||
{'name': 'resizeImage:50', 'label': '50%', 'value': '50'},
|
||||
{'name': 'resizeImage:75', 'label': '75%', 'value': '75'},
|
||||
],
|
||||
},
|
||||
'table': {
|
||||
'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells'],
|
||||
},
|
||||
'heading': {
|
||||
'options': [
|
||||
{'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph'},
|
||||
{'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1'},
|
||||
{'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2'},
|
||||
{'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3'},
|
||||
],
|
||||
},
|
||||
'codeBlock': {
|
||||
'languages': [
|
||||
{'language': 'python', 'label': 'Python'},
|
||||
{'language': 'javascript', 'label': 'JavaScript'},
|
||||
{'language': 'html', 'label': 'HTML'},
|
||||
{'language': 'css', 'label': 'CSS'},
|
||||
{'language': 'bash', 'label': 'Bash'},
|
||||
{'language': 'sql', 'label': 'SQL'},
|
||||
{'language': 'json', 'label': 'JSON'},
|
||||
],
|
||||
},
|
||||
},
|
||||
'extends': {
|
||||
'toolbar': [
|
||||
'heading', '|',
|
||||
'bold', 'italic', 'underline', 'strikethrough', 'subscript', 'superscript', '|',
|
||||
'link', 'blockQuote', 'code', 'codeBlock', '|',
|
||||
'bulletedList', 'numberedList', 'todoList', '|',
|
||||
'outdent', 'indent', 'alignment', '|',
|
||||
'imageUpload', 'insertTable', 'mediaEmbed', 'horizontalLine', '|',
|
||||
'highlight', 'removeFormat', '|',
|
||||
'undo', 'redo', '|',
|
||||
'sourceEditing',
|
||||
],
|
||||
'image': {
|
||||
'toolbar': [
|
||||
'imageTextAlternative', 'toggleImageCaption', '|',
|
||||
'imageStyle:inline', 'imageStyle:wrapText', 'imageStyle:breakText', '|',
|
||||
'resizeImage',
|
||||
],
|
||||
},
|
||||
'table': {
|
||||
'contentToolbar': ['tableColumn', 'tableRow', 'mergeTableCells', 'tableCellProperties', 'tableProperties'],
|
||||
},
|
||||
'heading': {
|
||||
'options': [
|
||||
{'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph'},
|
||||
{'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1'},
|
||||
{'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2'},
|
||||
{'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3'},
|
||||
{'model': 'heading4', 'view': 'h4', 'title': 'Heading 4', 'class': 'ck-heading_heading4'},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# CORS settings
|
||||
CORS_ALLOWED_ORIGINS = os.environ.get('CORS_ALLOWED_ORIGINS', 'http://localhost:3000').split(',')
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
# reCAPTCHA v3 settings
|
||||
RECAPTCHA_PUBLIC_KEY = os.environ.get('RECAPTCHA_PUBLIC_KEY', '')
|
||||
RECAPTCHA_PRIVATE_KEY = os.environ.get('RECAPTCHA_PRIVATE_KEY', '')
|
||||
RECAPTCHA_REQUIRED_SCORE = 0.5 # Score threshold (0.0 - 1.0)
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
"""
|
||||
Development settings for richardnixon.dev platform.
|
||||
"""
|
||||
from .base import *
|
||||
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# Use SQLite for local development if desired
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||
# 'NAME': BASE_DIR / 'db.sqlite3',
|
||||
# }
|
||||
# }
|
||||
|
||||
# Disable caching in development
|
||||
# CACHES = {
|
||||
# 'default': {
|
||||
# 'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
|
||||
# }
|
||||
# }
|
||||
|
||||
# Use simpler static files storage in development
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
|
||||
|
||||
# Email - output to console
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend'
|
||||
|
||||
# Django Debug Toolbar (uncomment if needed)
|
||||
# INSTALLED_APPS += ['debug_toolbar']
|
||||
# MIDDLEWARE.insert(0, 'debug_toolbar.middleware.DebugToolbarMiddleware')
|
||||
# INTERNAL_IPS = ['127.0.0.1']
|
||||
|
|
@ -1,88 +0,0 @@
|
|||
"""
|
||||
Production settings for richardnixon.dev platform.
|
||||
"""
|
||||
import os
|
||||
|
||||
from .base import *
|
||||
|
||||
DEBUG = False
|
||||
|
||||
# Sentry error tracking
|
||||
SENTRY_DSN = os.environ.get('SENTRY_DSN', '')
|
||||
if SENTRY_DSN:
|
||||
import sentry_sdk
|
||||
sentry_sdk.init(
|
||||
dsn=SENTRY_DSN,
|
||||
traces_sample_rate=0.1,
|
||||
send_default_pii=False,
|
||||
)
|
||||
|
||||
ALLOWED_HOSTS = [
|
||||
'richardnixon.dev',
|
||||
'www.richardnixon.dev',
|
||||
'localhost',
|
||||
'platform-web',
|
||||
]
|
||||
|
||||
# CSRF trusted origins for Traefik proxy
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'https://richardnixon.dev',
|
||||
'https://www.richardnixon.dev',
|
||||
]
|
||||
|
||||
# Security settings
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = False # Traefik handles SSL
|
||||
SESSION_COOKIE_SECURE = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SECURE_BROWSER_XSS_FILTER = True
|
||||
SECURE_CONTENT_TYPE_NOSNIFF = True
|
||||
X_FRAME_OPTIONS = 'DENY'
|
||||
|
||||
# HSTS (commented out until site is stable)
|
||||
# SECURE_HSTS_SECONDS = 31536000
|
||||
# SECURE_HSTS_INCLUDE_SUBDOMAINS = True
|
||||
# SECURE_HSTS_PRELOAD = True
|
||||
|
||||
# Email settings for production (configure SMTP)
|
||||
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||
EMAIL_HOST = os.environ.get('EMAIL_HOST', 'smtp.gmail.com')
|
||||
EMAIL_PORT = int(os.environ.get('EMAIL_PORT', 587))
|
||||
EMAIL_USE_TLS = True
|
||||
EMAIL_HOST_USER = os.environ.get('EMAIL_HOST_USER', '')
|
||||
EMAIL_HOST_PASSWORD = os.environ.get('EMAIL_HOST_PASSWORD', '')
|
||||
DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL', 'noreply@richardnixon.dev')
|
||||
|
||||
# Logging for production
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
|
||||
'style': '{',
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
},
|
||||
'loggers': {
|
||||
'django': {
|
||||
'handlers': ['console'],
|
||||
'level': 'WARNING',
|
||||
'propagate': False,
|
||||
},
|
||||
'apps': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
'propagate': False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
"""
|
||||
URL configuration for richardnixon.dev platform.
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path, include
|
||||
from django.conf import settings
|
||||
from django.views.static import serve
|
||||
from django.conf.urls.i18n import i18n_patterns
|
||||
from django.contrib.sitemaps.views import sitemap
|
||||
from two_factor.urls import urlpatterns as tf_urls
|
||||
from two_factor.admin import AdminSiteOTPRequired
|
||||
|
||||
from apps.api.api import api
|
||||
from apps.blog.sitemaps import BlogSitemap
|
||||
from apps.core.views import health
|
||||
from apps.portfolio.sitemaps import PortfolioSitemap
|
||||
|
||||
# Patch admin to require OTP
|
||||
admin.site.__class__ = AdminSiteOTPRequired
|
||||
|
||||
sitemaps = {
|
||||
'blog': BlogSitemap,
|
||||
'portfolio': PortfolioSitemap,
|
||||
}
|
||||
|
||||
# URLs that don't need language prefix
|
||||
urlpatterns = [
|
||||
path('health/', health, name='health'),
|
||||
path('sitemap.xml', sitemap, {'sitemaps': sitemaps}, name='django.contrib.sitemaps.views.sitemap'),
|
||||
path('i18n/', include('django.conf.urls.i18n')),
|
||||
path('ckeditor5/', include('django_ckeditor_5.urls')),
|
||||
path('api/', api.urls),
|
||||
]
|
||||
|
||||
# URLs with language prefix (/pt-br/, /en/)
|
||||
urlpatterns += i18n_patterns(
|
||||
path('', include(tf_urls)),
|
||||
path('admin/', admin.site.urls),
|
||||
path('accounts/', include('allauth.urls')),
|
||||
path('', include('apps.blog.urls')),
|
||||
path('portfolio/', include('apps.portfolio.urls')),
|
||||
path('contact/', include('apps.contact.urls')),
|
||||
prefix_default_language=True,
|
||||
)
|
||||
|
||||
# Serve media files in all environments (static() is empty when DEBUG=False)
|
||||
urlpatterns += [
|
||||
re_path(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
]
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
"""
|
||||
WSGI config for richardnixon.dev platform.
|
||||
"""
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production')
|
||||
|
||||
application = get_wsgi_application()
|
||||
21
conftest.py
21
conftest.py
|
|
@ -1,21 +0,0 @@
|
|||
import pytest
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user(db):
|
||||
return User.objects.create_user(
|
||||
email="test@example.com",
|
||||
password="testpass123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_user(db):
|
||||
return User.objects.create_superuser(
|
||||
email="admin@example.com",
|
||||
password="adminpass123",
|
||||
)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
gettext \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements/ requirements/
|
||||
RUN pip install --no-cache-dir -r requirements/production.txt
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
# Collect static files
|
||||
RUN python manage.py collectstatic --noinput --settings=config.settings.production || true
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Default command
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "config.wsgi:application"]
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
FROM python:3.12-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
netcat-openbsd \
|
||||
gettext \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements/ requirements/
|
||||
RUN pip install --no-cache-dir -r requirements/production.txt
|
||||
|
||||
# Copy project files
|
||||
COPY . .
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
# Create directories for static and media files
|
||||
RUN mkdir -p /app/staticfiles /app/media
|
||||
|
||||
# Create non-root user
|
||||
RUN useradd -m -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Set entrypoint
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
|
||||
# Default command
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--threads", "4", "config.wsgi:application"]
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Wait for PostgreSQL
|
||||
echo "Waiting for PostgreSQL..."
|
||||
while ! nc -z $POSTGRES_HOST ${POSTGRES_PORT:-5432}; do
|
||||
sleep 1
|
||||
done
|
||||
echo "PostgreSQL is ready!"
|
||||
|
||||
# Run migrations
|
||||
echo "Running migrations..."
|
||||
python manage.py migrate --noinput
|
||||
|
||||
# Collect static files
|
||||
echo "Collecting static files..."
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Create superuser if not exists (only if DJANGO_SUPERUSER_EMAIL is set)
|
||||
if [ -n "$DJANGO_SUPERUSER_EMAIL" ]; then
|
||||
echo "Creating superuser..."
|
||||
python manage.py createsuperuser --noinput || true
|
||||
fi
|
||||
|
||||
# Execute command
|
||||
exec "$@"
|
||||
|
|
@ -37,16 +37,14 @@ cp infrastructure/.env.example infrastructure/.env
|
|||
|
||||
Edit `infrastructure/.env` and fill in all required values. At minimum:
|
||||
|
||||
- `DJANGO_SECRET_KEY` — generate with:
|
||||
```bash
|
||||
python3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"
|
||||
```
|
||||
- `PLATFORM_DB_PASSWORD`, `LOCFLOW_DB_PASSWORD`, `UMAMI_DB_PASSWORD` — strong random passwords
|
||||
- `LOCFLOW_DB_PASSWORD`, `UMAMI_DB_PASSWORD` — strong random passwords
|
||||
- `LOCFLOW_SECRET_KEY`, `EIRESCOPE_SECRET_KEY` — `python3 -c "from secrets import token_urlsafe; print(token_urlsafe(50))"`
|
||||
- `MYSQL_ROOT_PASSWORD`, `WP_DB_PASSWORD` — for WordPress
|
||||
- `GRAFANA_ADMIN_PASSWORD`
|
||||
- `CROWDSEC_BOUNCER_KEY` — generated after first CrowdSec startup (see step 6)
|
||||
- `AUTHENTIK_SECRET_KEY`, `AUTHENTIK_DB_PASSWORD`
|
||||
- `FORGEJO_DB_PASSWORD`, `FORGEJO_SECRET_KEY`, `FORGEJO_INTERNAL_TOKEN`, `FORGEJO_OAUTH2_JWT_SECRET` — see Forgejo section below for generation commands
|
||||
- `FORGEJO_RUNNER_REGISTRATION_TOKEN` — generated after first Forgejo boot (see step 8)
|
||||
|
||||
## 4. Start core services
|
||||
|
||||
|
|
@ -55,19 +53,11 @@ cd infrastructure
|
|||
docker compose up -d
|
||||
```
|
||||
|
||||
This starts all services: Traefik, PostgreSQL, Redis, Django (legacy), Celery (legacy), Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, LocFlow, EireScope, Authentik.
|
||||
This starts every service: Traefik, Hugo blog-static, Forgejo + runner, WordPress, Umami, Grafana, Prometheus, Loki, Promtail, LocFlow, EireScope, Authentik, CrowdSec.
|
||||
|
||||
Traefik automatically provisions Let's Encrypt SSL certificates.
|
||||
|
||||
## 5. Run initial migrations and create superuser
|
||||
|
||||
```bash
|
||||
docker compose exec platform-web python manage.py migrate
|
||||
docker compose exec platform-web python manage.py createsuperuser
|
||||
docker compose exec platform-web python manage.py collectstatic --no-input
|
||||
```
|
||||
|
||||
## 6. Configure CrowdSec bouncer
|
||||
## 5. Configure CrowdSec bouncer
|
||||
|
||||
```bash
|
||||
docker compose exec crowdsec cscli bouncers add traefik-bouncer
|
||||
|
|
@ -79,24 +69,18 @@ Copy the generated key into `infrastructure/.env` as `CROWDSEC_BOUNCER_KEY`, the
|
|||
docker compose restart crowdsec-bouncer
|
||||
```
|
||||
|
||||
## 7. Verify deployment
|
||||
## 6. Verify deployment
|
||||
|
||||
```bash
|
||||
# Check all containers are healthy
|
||||
docker compose ps
|
||||
|
||||
# Smoke test
|
||||
curl https://richardnixon.dev/health/
|
||||
# Expected: {"status": "ok", "db": true, "cache": true}
|
||||
# Smoke test the public blog (served by Hugo blog-static)
|
||||
curl -I https://richardnixon.dev/
|
||||
curl -I https://richardnixon.dev/pt-br/
|
||||
```
|
||||
|
||||
## 8. Configure OAuth (optional)
|
||||
|
||||
1. Go to `https://richardnixon.dev/admin/`
|
||||
2. Under **Social applications**, add Google and/or GitHub OAuth providers
|
||||
3. Get credentials from Google Cloud Console / GitHub Developer Settings
|
||||
|
||||
## 9. Set up Forgejo Actions CI/CD for the Hugo blog
|
||||
## 7. Set up Forgejo Actions CI/CD for the Hugo blog
|
||||
|
||||
A `forgejo-runner` container is included in the compose and runs jobs on the host's Docker socket. After the first `docker compose up -d`:
|
||||
|
||||
|
|
@ -119,17 +103,16 @@ After a push to `main` on `richardnixon.dev-hugo`, the runner builds and the sit
|
|||
|
||||
## Updating
|
||||
|
||||
**Blog content** (`richardnixon.dev`): edit Markdown in the Hugo repo, `git push origin main`, Forgejo Actions deploys automatically. See `Richard/richardnixon.dev-hugo/.forgejo/workflows/deploy.yml`.
|
||||
**Blog content** (`richardnixon.dev`): edit Markdown in [the Hugo repo](https://git.richardnixon.dev/Richard/richardnixon.dev-hugo), `git push origin main` — Forgejo Actions builds and deploys automatically (~30–60s).
|
||||
|
||||
**Infrastructure + legacy Django**:
|
||||
**Infrastructure**:
|
||||
|
||||
```bash
|
||||
cd /root/richardnixon.dev
|
||||
git pull origin main
|
||||
docker compose -f infrastructure/docker-compose.yml build platform-web locflow-web eirescope
|
||||
docker compose -f infrastructure/docker-compose.yml build locflow-web eirescope locflow-frontend
|
||||
docker compose -f infrastructure/docker-compose.yml up -d
|
||||
docker compose -f infrastructure/docker-compose.yml exec -T platform-web python manage.py migrate --no-input
|
||||
docker compose -f infrastructure/docker-compose.yml exec -T platform-web python manage.py collectstatic --no-input
|
||||
docker compose -f infrastructure/docker-compose.yml exec -T locflow-web python manage.py migrate --no-input
|
||||
```
|
||||
|
||||
## Services and ports
|
||||
|
|
@ -138,10 +121,9 @@ All services are behind Traefik (ports 80/443). No service exposes ports directl
|
|||
|
||||
| Service | Internal port | URL |
|
||||
|---------|--------------|-----|
|
||||
| Hugo blog-static (nginx) | 80 | richardnixon.dev (catch-all, priority 100) |
|
||||
| Django platform (legacy) | 8000 | richardnixon.dev/{admin,api,static,media} (priority 110) |
|
||||
| Hugo blog-static (nginx) | 80 | richardnixon.dev (catch-all) |
|
||||
| LocFlow API | 8000 | locflow.richardnixon.dev |
|
||||
| EireScope | 5000 | osint.richardnixon.dev |
|
||||
| EireScope | 5000 | eirescope.richardnixon.dev |
|
||||
| WordPress | 80 | richardemanu.com |
|
||||
| Umami | 3000 | analytics.richardnixon.dev |
|
||||
| Grafana | 3000 | status.richardnixon.dev |
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
# Local Development Guide
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.12+
|
||||
- PostgreSQL 16 and Redis 7 (or Docker for services only)
|
||||
- Node.js 22+ (for the frontend)
|
||||
|
||||
## Option A: Docker Compose (recommended)
|
||||
|
||||
Use Docker Compose for databases, run Django directly.
|
||||
|
||||
### 1. Start services
|
||||
|
||||
```bash
|
||||
docker compose -f infrastructure/docker-compose.yml up -d platform-db platform-redis
|
||||
```
|
||||
|
||||
### 2. Set up Python environment
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements/dev.txt
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
|
||||
```bash
|
||||
export DJANGO_SETTINGS_MODULE=config.settings.development
|
||||
export POSTGRES_DB=platform
|
||||
export POSTGRES_USER=platform
|
||||
export POSTGRES_PASSWORD=platform
|
||||
export POSTGRES_HOST=localhost
|
||||
export POSTGRES_PORT=5432
|
||||
export REDIS_URL=redis://localhost:6379/0
|
||||
export CELERY_BROKER_URL=redis://localhost:6379/1
|
||||
```
|
||||
|
||||
Or create a `.env` file and use `python-dotenv`.
|
||||
|
||||
### 4. Run migrations and start server
|
||||
|
||||
```bash
|
||||
python manage.py migrate
|
||||
python manage.py createsuperuser
|
||||
python manage.py runserver
|
||||
```
|
||||
|
||||
Visit http://localhost:8000/
|
||||
|
||||
### 5. Run Celery (optional)
|
||||
|
||||
In a separate terminal:
|
||||
|
||||
```bash
|
||||
celery -A config worker -l info
|
||||
celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
```
|
||||
|
||||
## Option B: Full Docker stack
|
||||
|
||||
Run everything in Docker (mirrors production):
|
||||
|
||||
```bash
|
||||
cp infrastructure/.env.example infrastructure/.env
|
||||
# Edit .env with local values
|
||||
|
||||
docker compose -f infrastructure/docker-compose.yml up -d platform-db platform-redis platform-web
|
||||
```
|
||||
|
||||
## Frontend development
|
||||
|
||||
The Next.js frontend lives in `frontend/`.
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit http://localhost:3000/
|
||||
|
||||
The frontend proxies API calls to the Django backend. Set `API_URL=http://localhost:8000/api` if the backend runs on a different port.
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
# All tests
|
||||
pytest apps/ -v
|
||||
|
||||
# With coverage
|
||||
pytest apps/ --cov=apps --cov-report=term-missing
|
||||
|
||||
# Single app
|
||||
pytest apps/accounts/ -v
|
||||
```
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
ruff check .
|
||||
ruff format --check .
|
||||
mypy apps/
|
||||
```
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
# Create new migrations
|
||||
python manage.py makemigrations
|
||||
|
||||
# Compile translations
|
||||
python manage.py compilemessages
|
||||
|
||||
# Collect static files
|
||||
python manage.py collectstatic
|
||||
|
||||
# Django shell
|
||||
python manage.py shell
|
||||
|
||||
# Check for issues
|
||||
python manage.py check
|
||||
```
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
3
frontend/.gitignore
vendored
3
frontend/.gitignore
vendored
|
|
@ -1,3 +0,0 @@
|
|||
node_modules/
|
||||
.next/
|
||||
out/
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
FROM node:22-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ARG NEXT_PUBLIC_API_URL
|
||||
ARG NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
ENV NEXT_PUBLIC_UMAMI_WEBSITE_ID=$NEXT_PUBLIC_UMAMI_WEBSITE_ID
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
ENV PORT=3000
|
||||
CMD ["node", "server.js"]
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "richardnixon.dev",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
1564
frontend/package-lock.json
generated
1564
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"name": "richardnixon-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "^15.1.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"highlight.js": "^11.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@tailwindcss/postcss": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"postcss": "^8.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getPost, getRelatedPosts } from "@/lib/api";
|
||||
import PostCard from "@/components/PostCard";
|
||||
import type { Metadata } from "next";
|
||||
import Highlight from "@/components/Highlight";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await getPost(slug);
|
||||
return {
|
||||
title: post.title,
|
||||
description: post.meta_description || post.excerpt,
|
||||
keywords: post.meta_keywords || undefined,
|
||||
openGraph: {
|
||||
title: post.title,
|
||||
description: post.meta_description || post.excerpt,
|
||||
type: "article",
|
||||
...(post.featured_image && { images: [post.featured_image] }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [post, related] = await Promise.all([
|
||||
getPost(slug),
|
||||
getRelatedPosts(slug),
|
||||
]);
|
||||
|
||||
const date = post.published_at
|
||||
? new Date(post.published_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
|
||||
const shareUrl = `https://richardnixon.dev/blog/${post.slug}`;
|
||||
|
||||
return (
|
||||
<article className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
{post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{post.tags.map((tag) => (
|
||||
<Link
|
||||
key={tag.slug}
|
||||
href={`/blog?tag=${tag.slug}`}
|
||||
className="text-xs px-2 py-1 bg-primary-900/50 text-primary-300 rounded hover:bg-primary-800/50 transition"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-4">{post.title}</h1>
|
||||
<div className="flex items-center text-sm text-gray-400 gap-4">
|
||||
<span>{date}</span>
|
||||
<span>{post.reading_time} min read</span>
|
||||
{post.author_name && <span>by {post.author_name}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Image */}
|
||||
{post.featured_image && (
|
||||
<div className="relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className="prose text-gray-300 max-w-none mb-12"
|
||||
dangerouslySetInnerHTML={{ __html: post.content }}
|
||||
/>
|
||||
<Highlight />
|
||||
|
||||
{/* Share */}
|
||||
<div className="flex items-center gap-4 py-6 border-t border-gray-700">
|
||||
<span className="text-sm text-gray-400">Share:</span>
|
||||
<a
|
||||
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(post.title)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-400 hover:text-white transition"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
<a
|
||||
href={`https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-gray-400 hover:text-white transition"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Back */}
|
||||
<Link href="/blog" className="inline-block text-primary-400 hover:text-primary-300 text-sm transition mt-4">
|
||||
← Back to Blog
|
||||
</Link>
|
||||
|
||||
{/* Related Posts */}
|
||||
{related.length > 0 && (
|
||||
<section className="mt-16">
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Related Posts</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{related.map((p) => (
|
||||
<PostCard key={p.id} post={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import PostCard from "@/components/PostCard";
|
||||
import { getPosts, getTags, getPostsCount } from "@/lib/api";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blog",
|
||||
description: "Articles about software engineering, technology, and more.",
|
||||
};
|
||||
|
||||
export default async function BlogPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ page?: string; search?: string; tag?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const page = Number(params.page) || 1;
|
||||
const search = params.search || "";
|
||||
const tag = params.tag || "";
|
||||
|
||||
const [posts, tags, total] = await Promise.all([
|
||||
getPosts({ page, search, tag }),
|
||||
getTags(),
|
||||
getPostsCount({ search, tag }),
|
||||
]);
|
||||
|
||||
const totalPages = Math.ceil(total / 10);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Blog</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Posts */}
|
||||
<div className="flex-1">
|
||||
{/* Search */}
|
||||
<form className="mb-8">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
name="search"
|
||||
defaultValue={search}
|
||||
placeholder="Search posts..."
|
||||
className="flex-1 bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 placeholder-gray-500 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-gray-400">No posts found.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center gap-2 mt-8">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={`/blog?page=${p}${search ? `&search=${search}` : ""}${tag ? `&tag=${tag}` : ""}`}
|
||||
className={`px-3 py-1 rounded ${
|
||||
p === page
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-gray-800 text-gray-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:w-72">
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href="/blog"
|
||||
className={`text-sm px-3 py-1 rounded-full transition ${
|
||||
!tag
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-gray-700 text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{tags.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`/blog?tag=${t.slug}`}
|
||||
className={`text-sm px-3 py-1 rounded-full transition ${
|
||||
tag === t.slug
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-gray-700 text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 bg-gray-800 border border-gray-700 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Subscribe</h3>
|
||||
<p className="text-gray-400 text-sm mb-3">Stay updated with the RSS feed.</p>
|
||||
<a
|
||||
href="/feed/"
|
||||
className="inline-block text-sm text-primary-400 hover:text-primary-300 transition"
|
||||
>
|
||||
RSS Feed →
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,148 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { submitContact } from "@/lib/api";
|
||||
|
||||
export default function ContactPage() {
|
||||
const [form, setForm] = useState({ name: "", email: "", subject: "", message: "" });
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setStatus("sending");
|
||||
try {
|
||||
await submitContact(form);
|
||||
setStatus("sent");
|
||||
setForm({ name: "", email: "", subject: "", message: "" });
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Contact</h1>
|
||||
<p className="text-gray-400 mb-8">
|
||||
Feel free to reach out. I'll get back to you as soon as possible.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-8">
|
||||
{/* Form */}
|
||||
<div className="flex-1">
|
||||
{status === "sent" && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-green-900 text-green-100">
|
||||
Message sent successfully!
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-red-900 text-red-100">
|
||||
Something went wrong. Please try again.
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
required
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Subject
|
||||
</label>
|
||||
<input
|
||||
id="subject"
|
||||
type="text"
|
||||
required
|
||||
value={form.subject}
|
||||
onChange={(e) => setForm({ ...form, subject: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-1">
|
||||
Message
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
rows={6}
|
||||
required
|
||||
value={form.message}
|
||||
onChange={(e) => setForm({ ...form, message: e.target.value })}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-2 text-gray-100 focus:outline-none focus:border-primary-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "sending"}
|
||||
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg transition font-medium"
|
||||
>
|
||||
{status === "sending" ? "Sending..." : "Send Message"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:w-72 space-y-4">
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-3">Resume</h3>
|
||||
<p className="text-gray-400 text-sm mb-3">Download my latest resume.</p>
|
||||
<a
|
||||
href={`${process.env.NEXT_PUBLIC_API_URL || ""}/contact/resume`}
|
||||
className="inline-block px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
|
||||
>
|
||||
Download Resume
|
||||
</a>
|
||||
</div>
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5">
|
||||
<h3 className="text-lg font-semibold text-white mb-3">Social</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/richardnixondev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white text-sm transition"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://linkedin.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white text-sm transition"
|
||||
>
|
||||
LinkedIn
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Primary (Indigo) */
|
||||
--color-primary-50: #eef2ff;
|
||||
--color-primary-100: #e0e7ff;
|
||||
--color-primary-200: #c7d2fe;
|
||||
--color-primary-300: #a5b4fc;
|
||||
--color-primary-400: #818cf8;
|
||||
--color-primary-500: #6366f1;
|
||||
--color-primary-600: #4f46e5;
|
||||
--color-primary-700: #4338ca;
|
||||
--color-primary-800: #3730a3;
|
||||
--color-primary-900: #312e81;
|
||||
|
||||
/* Gray - match Tailwind v3 hex values for consistent look */
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
--color-gray-950: #030712;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
::-webkit-scrollbar-track { background: #1f2937; }
|
||||
::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||
|
||||
/* Prose styles for blog content */
|
||||
.prose pre { background: #1f2937; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
|
||||
.prose code { background: #374151; padding: 0.125rem 0.25rem; border-radius: 0.25rem; font-size: 0.875em; }
|
||||
.prose pre code { background: transparent; padding: 0; }
|
||||
.prose h1, .prose h2, .prose h3, .prose h4 { color: #f9fafb; font-weight: 700; }
|
||||
.prose h1 { font-size: 2rem; margin-top: 2rem; margin-bottom: 1rem; }
|
||||
.prose h2 { font-size: 1.5rem; margin-top: 1.75rem; margin-bottom: 0.75rem; }
|
||||
.prose h3 { font-size: 1.25rem; margin-top: 1.5rem; margin-bottom: 0.5rem; }
|
||||
.prose p { margin-bottom: 1rem; line-height: 1.75; }
|
||||
.prose a { color: #818cf8; text-decoration: underline; }
|
||||
.prose a:hover { color: #a5b4fc; }
|
||||
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
|
||||
.prose li { margin-bottom: 0.25rem; }
|
||||
.prose ul { list-style-type: disc; }
|
||||
.prose ol { list-style-type: decimal; }
|
||||
.prose img { border-radius: 0.5rem; margin: 1.5rem 0; }
|
||||
.prose blockquote { border-left: 4px solid #4f46e5; padding-left: 1rem; margin: 1.5rem 0; color: #9ca3af; font-style: italic; }
|
||||
.prose table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; }
|
||||
.prose th, .prose td { border: 1px solid #374151; padding: 0.5rem 0.75rem; }
|
||||
.prose th { background: #1f2937; font-weight: 600; }
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Footer from "@/components/Footer";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
|
||||
const inter = Inter({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: "richardnixon.dev",
|
||||
template: "%s | richardnixon.dev",
|
||||
},
|
||||
description: "Personal platform - Blog, Portfolio, and Dashboards",
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "richardnixon.dev",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const umamiId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<body className={`${inter.className} bg-gray-900 text-gray-100 min-h-screen flex flex-col`}>
|
||||
<Navbar />
|
||||
<main className="flex-grow">{children}</main>
|
||||
<Footer />
|
||||
{umamiId && (
|
||||
<Script
|
||||
defer
|
||||
src="https://analytics.richardnixon.dev/script.js"
|
||||
data-website-id={umamiId}
|
||||
/>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import PostCard from "@/components/PostCard";
|
||||
import { getHomeData } from "@/lib/api";
|
||||
|
||||
export default async function Home() {
|
||||
const { recent_posts } = await getHomeData();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero */}
|
||||
<section className="bg-gradient-to-br from-gray-900 via-gray-800 to-primary-900/20 py-20">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl md:text-6xl font-bold text-white mb-6">
|
||||
Hi, I'm{" "}
|
||||
<span className="text-transparent bg-clip-text bg-gradient-to-r from-primary-400 to-primary-600">
|
||||
Richard Nixon
|
||||
</span>
|
||||
</h1>
|
||||
<p className="text-xl text-gray-300 mb-8 max-w-2xl mx-auto">
|
||||
Software Engineer sharing thoughts, projects, and data visualizations.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition font-medium"
|
||||
>
|
||||
Read Blog
|
||||
</Link>
|
||||
<Link
|
||||
href="/portfolio"
|
||||
className="px-6 py-3 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition font-medium"
|
||||
>
|
||||
View Portfolio
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Recent Posts */}
|
||||
{recent_posts.length > 0 && (
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h2 className="text-2xl font-bold text-white">Recent Posts</h2>
|
||||
<Link href="/blog" className="text-primary-400 hover:text-primary-300 text-sm transition">
|
||||
View all →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{recent_posts.slice(0, 3).map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Quick Links */}
|
||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-16">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{[
|
||||
{ href: "/portfolio", title: "Portfolio", desc: "Check out my projects and work." },
|
||||
{ href: "/blog", title: "Blog", desc: "Read my latest articles and thoughts." },
|
||||
{ href: "/contact", title: "Contact", desc: "Get in touch or download my resume." },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block bg-gray-800 border border-gray-700 rounded-lg p-6 hover:border-primary-500 transition group"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-2">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">{item.desc}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { getProject, getRelatedProjects } from "@/lib/api";
|
||||
import ProjectCard from "@/components/ProjectCard";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
type Props = { params: Promise<{ slug: string }> };
|
||||
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = await getProject(slug);
|
||||
return {
|
||||
title: project.title,
|
||||
description: project.tagline,
|
||||
openGraph: {
|
||||
title: project.title,
|
||||
description: project.tagline,
|
||||
...(project.featured_image && { images: [project.featured_image] }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ProjectPage({ params }: Props) {
|
||||
const { slug } = await params;
|
||||
const [project, related] = await Promise.all([
|
||||
getProject(slug),
|
||||
getRelatedProjects(slug),
|
||||
]);
|
||||
|
||||
const formatDate = (d: string | null) =>
|
||||
d ? new Date(d).toLocaleDateString("en-US", { year: "numeric", month: "short" }) : null;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link href="/portfolio" className="text-primary-400 hover:text-primary-300 text-sm transition">
|
||||
← Back to Portfolio
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mt-6 mb-8">
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">{project.title}</h1>
|
||||
<p className="text-xl text-gray-400 mb-4">{project.tagline}</p>
|
||||
|
||||
{/* Technologies */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.technologies.map((tech) => (
|
||||
<span
|
||||
key={tech.slug}
|
||||
className="text-sm px-3 py-1 rounded-full"
|
||||
style={{ backgroundColor: `${tech.color}20`, color: tech.color }}
|
||||
>
|
||||
{tech.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{project.live_url && (
|
||||
<a
|
||||
href={project.live_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition text-sm"
|
||||
>
|
||||
Live Demo
|
||||
</a>
|
||||
)}
|
||||
{project.github_url && (
|
||||
<a
|
||||
href={project.github_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
|
||||
>
|
||||
View Code
|
||||
</a>
|
||||
)}
|
||||
{project.documentation_url && (
|
||||
<a
|
||||
href={project.documentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-4 py-2 bg-gray-700 hover:bg-gray-600 text-white rounded-lg transition text-sm"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Featured Image */}
|
||||
{project.featured_image && (
|
||||
<div className="relative w-full h-64 md:h-96 mb-8 rounded-lg overflow-hidden shadow-xl">
|
||||
<Image
|
||||
src={project.featured_image}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
<div
|
||||
className="prose text-gray-300 max-w-none mb-8"
|
||||
dangerouslySetInnerHTML={{ __html: project.description }}
|
||||
/>
|
||||
|
||||
{/* Gallery */}
|
||||
{project.images.length > 0 && (
|
||||
<section className="mb-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">Gallery</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{project.images.map((img, i) => (
|
||||
<div key={i} className="relative h-64 rounded-lg overflow-hidden">
|
||||
<Image
|
||||
src={img.image}
|
||||
alt={img.caption || `${project.title} screenshot ${i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{img.caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-2 text-sm text-gray-200">
|
||||
{img.caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Project Details */}
|
||||
<div className="bg-gray-800 border border-gray-700 rounded-lg p-5 mb-8">
|
||||
<h3 className="text-lg font-semibold text-white mb-3">Project Details</h3>
|
||||
<dl className="grid grid-cols-2 gap-3 text-sm">
|
||||
{project.start_date && (
|
||||
<>
|
||||
<dt className="text-gray-400">Started</dt>
|
||||
<dd className="text-gray-200">{formatDate(project.start_date)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt className="text-gray-400">Status</dt>
|
||||
<dd className="text-gray-200">
|
||||
{project.is_ongoing ? (
|
||||
<span className="text-green-400">Ongoing</span>
|
||||
) : (
|
||||
formatDate(project.end_date) || "Completed"
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Related Projects */}
|
||||
{related.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-2xl font-bold text-white mb-6">Related Projects</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{related.map((p) => (
|
||||
<ProjectCard key={p.id} project={p} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
export const dynamic = "force-dynamic";
|
||||
|
||||
import Link from "next/link";
|
||||
import ProjectCard from "@/components/ProjectCard";
|
||||
import { getProjects, getTechnologies, getFeaturedProjects } from "@/lib/api";
|
||||
import type { Metadata } from "next";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Portfolio",
|
||||
description: "Projects and work showcase.",
|
||||
};
|
||||
|
||||
export default async function PortfolioPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ tech?: string }>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const tech = params.tech || "";
|
||||
|
||||
const [projects, technologies, featured] = await Promise.all([
|
||||
getProjects(tech || undefined),
|
||||
getTechnologies(),
|
||||
tech ? Promise.resolve([]) : getFeaturedProjects(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<h1 className="text-3xl font-bold text-white mb-8">Portfolio</h1>
|
||||
|
||||
{/* Technology Filter */}
|
||||
<div className="flex flex-wrap gap-2 mb-8">
|
||||
<Link
|
||||
href="/portfolio"
|
||||
className={`text-sm px-3 py-1 rounded-full transition ${
|
||||
!tech
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-gray-700 text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</Link>
|
||||
{technologies.map((t) => (
|
||||
<Link
|
||||
key={t.slug}
|
||||
href={`/portfolio?tech=${t.slug}`}
|
||||
className={`text-sm px-3 py-1 rounded-full transition ${
|
||||
tech === t.slug
|
||||
? "bg-primary-600 text-white"
|
||||
: "bg-gray-700 text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Featured */}
|
||||
{featured.length > 0 && (
|
||||
<section className="mb-12">
|
||||
<h2 className="text-xl font-semibold text-white mb-4">Featured</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{featured.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* All Projects */}
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-gray-400">No projects found.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project) => (
|
||||
<ProjectCard key={project.id} project={project} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import Link from "next/link";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="bg-gray-800 border-t border-gray-700 mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">richardnixon.dev</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Personal platform for sharing thoughts, projects, and data.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Links</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<a href="/feed/" className="text-gray-400 hover:text-white text-sm">
|
||||
RSS Feed
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/contact" className="text-gray-400 hover:text-white text-sm">
|
||||
Download Resume
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/richardnixondev"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-gray-400 hover:text-white text-sm"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Connect</h3>
|
||||
<ul className="space-y-2">
|
||||
<li>
|
||||
<Link href="/contact" className="text-gray-400 hover:text-white text-sm">
|
||||
Contact Form
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 pt-8 border-t border-gray-700 text-center text-gray-500 text-sm">
|
||||
© {new Date().getFullYear()} richardnixon.dev. All rights reserved.
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import hljs from "highlight.js";
|
||||
import "highlight.js/styles/github-dark.css";
|
||||
|
||||
export default function Highlight() {
|
||||
useEffect(() => {
|
||||
hljs.highlightAll();
|
||||
}, []);
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1,120 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
const languages = [
|
||||
{ code: "en", label: "English" },
|
||||
{ code: "pt-br", label: "Português (Brasil)" },
|
||||
];
|
||||
|
||||
export default function Navbar() {
|
||||
const pathname = usePathname();
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
|
||||
const links = [
|
||||
{ href: "/blog", label: "Blog" },
|
||||
{ href: "/portfolio", label: "Portfolio" },
|
||||
{ href: "/contact", label: "Contact" },
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-800 border-b border-gray-700 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex items-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xl font-bold text-primary-400 hover:text-primary-300 transition"
|
||||
>
|
||||
richardnixon.dev
|
||||
</Link>
|
||||
<div className="hidden md:ml-10 md:flex md:space-x-8">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={`px-3 py-2 transition ${
|
||||
pathname.startsWith(link.href)
|
||||
? "text-white border-b-2 border-primary-500"
|
||||
: "text-gray-300 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* Language Switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setLangOpen(!langOpen)}
|
||||
onBlur={() => setTimeout(() => setLangOpen(false), 150)}
|
||||
className="flex items-center text-gray-300 hover:text-white text-sm px-2 py-1 rounded hover:bg-gray-700 transition"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
EN
|
||||
</button>
|
||||
{langOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-gray-800 border border-gray-700 rounded-md shadow-lg py-1 z-50">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => {
|
||||
setLangOpen(false);
|
||||
// Language change will be handled by Django API i18n in the future
|
||||
}}
|
||||
className="block w-full text-left px-4 py-2 text-sm text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
{lang.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(!mobileOpen)}
|
||||
className="md:hidden p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{mobileOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden bg-gray-800 border-t border-gray-700">
|
||||
<div className="px-2 pt-2 pb-3 space-y-1">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block px-3 py-2 text-gray-300 hover:text-white hover:bg-gray-700 rounded-md"
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Post } from "@/lib/types";
|
||||
|
||||
export default function PostCard({ post }: { post: Post }) {
|
||||
const date = post.published_at
|
||||
? new Date(post.published_at).toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<article className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-primary-500 transition group">
|
||||
{post.featured_image && (
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={post.featured_image}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<div className="p-5">
|
||||
{post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<Link
|
||||
key={tag.slug}
|
||||
href={`/blog?tag=${tag.slug}`}
|
||||
className="text-xs px-2 py-1 bg-primary-900/50 text-primary-300 rounded hover:bg-primary-800/50 transition"
|
||||
>
|
||||
{tag.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Link href={`/blog/${post.slug}`}>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-2">
|
||||
{post.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-gray-400 text-sm line-clamp-2 mb-3">{post.excerpt}</p>
|
||||
<div className="flex items-center text-xs text-gray-500 gap-3">
|
||||
<span>{date}</span>
|
||||
<span>{post.reading_time} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import type { Project } from "@/lib/types";
|
||||
|
||||
export default function ProjectCard({ project }: { project: Project }) {
|
||||
const image = project.thumbnail || project.featured_image;
|
||||
|
||||
return (
|
||||
<article className="bg-gray-800 rounded-lg overflow-hidden border border-gray-700 hover:border-primary-500 transition group">
|
||||
{image && (
|
||||
<Link href={`/portfolio/${project.slug}`}>
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={image}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<div className="p-5">
|
||||
<Link href={`/portfolio/${project.slug}`}>
|
||||
<h3 className="text-lg font-semibold text-white group-hover:text-primary-400 transition mb-1">
|
||||
{project.title}
|
||||
</h3>
|
||||
</Link>
|
||||
<p className="text-gray-400 text-sm mb-3">{project.tagline}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{project.technologies.slice(0, 4).map((tech) => (
|
||||
<span
|
||||
key={tech.slug}
|
||||
className="text-xs px-2 py-1 rounded"
|
||||
style={{ backgroundColor: `${tech.color}20`, color: tech.color }}
|
||||
>
|
||||
{tech.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{project.github_url && (
|
||||
<a
|
||||
href={project.github_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-gray-400 hover:text-white transition"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
)}
|
||||
{project.live_url && (
|
||||
<a
|
||||
href={project.live_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition"
|
||||
>
|
||||
Live Demo
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
@ -19,7 +19,6 @@ services:
|
|||
- traefik-logs:/var/log/traefik
|
||||
networks:
|
||||
- web
|
||||
- platform-internal
|
||||
- wordpress-internal
|
||||
- locflow-internal
|
||||
- crowdsec
|
||||
|
|
@ -68,153 +67,6 @@ services:
|
|||
- "traefik.http.routers.wordpress.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.wordpress.loadbalancer.server.port=80"
|
||||
|
||||
# ===========================================
|
||||
# DJANGO PLATFORM - richardnixon.dev
|
||||
# ===========================================
|
||||
platform-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: platform-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
|
||||
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
|
||||
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
|
||||
volumes:
|
||||
- platform-db-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- platform-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${PLATFORM_DB_USER:-platform}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
platform-redis:
|
||||
image: redis:7-alpine
|
||||
container_name: platform-redis
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- platform-redis-data:/data
|
||||
networks:
|
||||
- platform-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
platform-web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile.full
|
||||
container_name: platform-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
platform-db:
|
||||
condition: service_healthy
|
||||
platform-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
|
||||
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
|
||||
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
|
||||
POSTGRES_HOST: platform-db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://platform-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://platform-redis:6379/1
|
||||
DJANGO_SUPERUSER_EMAIL: ${DJANGO_SUPERUSER_EMAIL:-}
|
||||
DJANGO_SUPERUSER_PASSWORD: ${DJANGO_SUPERUSER_PASSWORD:-}
|
||||
RECAPTCHA_PUBLIC_KEY: ${RECAPTCHA_PUBLIC_KEY:-}
|
||||
RECAPTCHA_PRIVATE_KEY: ${RECAPTCHA_PRIVATE_KEY:-}
|
||||
UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
|
||||
SENTRY_DSN: ${SENTRY_DSN:-}
|
||||
CORS_ALLOWED_ORIGINS: https://richardnixon.dev,https://www.richardnixon.dev
|
||||
volumes:
|
||||
- platform-static:/app/staticfiles
|
||||
- platform-media:/app/media
|
||||
networks:
|
||||
- web
|
||||
- platform-internal
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "--fail", "http://localhost:8000/health/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.services.platform-api.loadbalancer.server.port=8000"
|
||||
|
||||
platform-frontend:
|
||||
build:
|
||||
context: ../frontend
|
||||
args:
|
||||
NEXT_PUBLIC_API_URL: https://richardnixon.dev/api
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID:-}
|
||||
container_name: platform-frontend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- platform-web
|
||||
environment:
|
||||
API_URL: http://platform-web:8000/api
|
||||
networks:
|
||||
- web
|
||||
- platform-internal
|
||||
|
||||
platform-celery:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: platform-celery
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
platform-db:
|
||||
condition: service_healthy
|
||||
platform-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
|
||||
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
|
||||
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
|
||||
POSTGRES_HOST: platform-db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://platform-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://platform-redis:6379/1
|
||||
command: celery -A config worker -l info
|
||||
volumes:
|
||||
- platform-media:/app/media
|
||||
networks:
|
||||
- platform-internal
|
||||
|
||||
platform-celery-beat:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: docker/Dockerfile
|
||||
container_name: platform-celery-beat
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
platform-db:
|
||||
condition: service_healthy
|
||||
platform-redis:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DJANGO_SETTINGS_MODULE: config.settings.production
|
||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
|
||||
POSTGRES_DB: ${PLATFORM_DB_NAME:-platform}
|
||||
POSTGRES_USER: ${PLATFORM_DB_USER:-platform}
|
||||
POSTGRES_PASSWORD: ${PLATFORM_DB_PASSWORD}
|
||||
POSTGRES_HOST: platform-db
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://platform-redis:6379/0
|
||||
CELERY_BROKER_URL: redis://platform-redis:6379/1
|
||||
command: celery -A config beat -l info --scheduler django_celery_beat.schedulers:DatabaseScheduler
|
||||
networks:
|
||||
- platform-internal
|
||||
|
||||
# ===========================================
|
||||
# FORGEJO RUNNER - CI for self-hosted git
|
||||
# ===========================================
|
||||
|
|
@ -375,32 +227,6 @@ services:
|
|||
networks:
|
||||
- monitoring
|
||||
|
||||
postgres-exporter:
|
||||
image: prometheuscommunity/postgres-exporter:latest
|
||||
container_name: postgres-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
DATA_SOURCE_NAME: "postgresql://${PLATFORM_DB_USER:-platform}:${PLATFORM_DB_PASSWORD}@platform-db:5432/${PLATFORM_DB_NAME:-platform}?sslmode=disable"
|
||||
networks:
|
||||
- monitoring
|
||||
- platform-internal
|
||||
depends_on:
|
||||
platform-db:
|
||||
condition: service_healthy
|
||||
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
container_name: redis-exporter
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
REDIS_ADDR: "redis://platform-redis:6379"
|
||||
networks:
|
||||
- monitoring
|
||||
- platform-internal
|
||||
depends_on:
|
||||
platform-redis:
|
||||
condition: service_healthy
|
||||
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: grafana
|
||||
|
|
@ -817,8 +643,6 @@ networks:
|
|||
driver: bridge
|
||||
wordpress-internal:
|
||||
driver: bridge
|
||||
platform-internal:
|
||||
driver: bridge
|
||||
umami-internal:
|
||||
driver: bridge
|
||||
locflow-internal:
|
||||
|
|
@ -839,10 +663,6 @@ volumes:
|
|||
traefik-logs:
|
||||
wordpress-db-data:
|
||||
wordpress-data:
|
||||
platform-db-data:
|
||||
platform-redis-data:
|
||||
platform-static:
|
||||
platform-media:
|
||||
umami-db-data:
|
||||
locflow-db-data:
|
||||
locflow-static:
|
||||
|
|
|
|||
|
|
@ -74,18 +74,6 @@ http:
|
|||
|
||||
# === PUBLIC ROUTERS (crowdsec only) ===
|
||||
|
||||
# Django admin/API only (narrowed after Hugo cutover; Hugo owns blog content)
|
||||
platform-api:
|
||||
rule: "(Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)) && (PathPrefix(`/api`) || PathPrefix(`/admin`) || PathPrefix(`/static`) || PathPrefix(`/media`))"
|
||||
entryPoints:
|
||||
- websecure
|
||||
service: platform-api
|
||||
middlewares:
|
||||
- crowdsec-bouncer
|
||||
priority: 110
|
||||
tls:
|
||||
certResolver: letsencrypt
|
||||
|
||||
# Hugo static blog (catch-all for richardnixon.dev / www.richardnixon.dev)
|
||||
blog-static:
|
||||
rule: "Host(`richardnixon.dev`) || Host(`www.richardnixon.dev`)"
|
||||
|
|
@ -197,16 +185,6 @@ http:
|
|||
certResolver: letsencrypt
|
||||
|
||||
services:
|
||||
platform-api:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://platform-web:8000"
|
||||
|
||||
platform-frontend:
|
||||
loadBalancer:
|
||||
servers:
|
||||
- url: "http://platform-frontend:3000"
|
||||
|
||||
blog-static:
|
||||
loadBalancer:
|
||||
servers:
|
||||
|
|
|
|||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue