Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 155 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,155 @@
# backend

# Ideological Atlas - Backend API

![CI](https://github.com/Ideological-Atlas/backend/actions/workflows/cicd.yml/badge.svg)
[![codecov](https://codecov.io/gh/Ideological-Atlas/backend/graph/badge.svg?token=W9D4BVTK2Y)](https://app.codecov.io/gh/Ideological-Atlas/backend)
![Python](https://img.shields.io/badge/python-3.14-blue.svg)
![Django](https://img.shields.io/badge/django-6.0-092E20?logo=django&logoColor=white)
![License](https://img.shields.io/badge/license-MIT-green)

[![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit)](https://github.com/pre-commit/pre-commit)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![Imports: isort](https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336)](https://pycqa.github.io/isort/)
[![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)

[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)
[![gitleaks](https://img.shields.io/badge/protected%20by-gitleaks-blueviolet)](https://github.com/gitleaks/gitleaks)
[![shellcheck](https://img.shields.io/badge/shellcheck-enforced-4EAA25)](https://github.com/koalaman/shellcheck)
[![codespell](https://img.shields.io/badge/spell%20check-codespell-blue)](https://github.com/codespell-project/codespell)

## 📖 Description

This repository contains the **Backend API** for the *Ideological Atlas* project. It is a robust and scalable application designed to manage users, complex ideological test structures, affinity calculations, and geographic/ideological content management.

The system is built on a modern container-based architecture, utilizing the latest stable versions of its core technologies.

## 🛠️ Technology Stack

The infrastructure relies on the following main components:

* **Language:** Python 3.14
* **Web Framework:** Django 6.0
* **API:** Django Rest Framework (DRF) 3.16+
* **Database:** PostgreSQL 18
* **Package Manager:** uv
* **Async & Tasks:** Celery 5.6 + RabbitMQ 4
* **Storage:** MinIO (S3 Compatible)
* **Containerization:** Docker & Docker Compose

## 🚀 Installation and Local Deployment

The project is fully containerized to facilitate development. A `Makefile` is used to orchestrate the most common commands.

### Prerequisites

* Docker Engine
* Docker Compose
* Make (Optional, but recommended)

### Getting Started

1. **Clone the repository:**
```bash
git clone https://github.com/Ideological-Atlas/backend.git
cd backend
```

2. **Build and start the environment:**
This command creates the network, generates the `.env` file from the template, builds the images, and starts the containers in the background.
```bash
make build
```

3. **Check status:**
You can view real-time logs to ensure everything started correctly (Backend, Worker, Beat, Flower, Postgres, MinIO).
```bash
make logs
```

The backend will be available at `http://localhost:3141` (or the port defined in your `.env`).

## ⚙️ Project Management (Makefile)

The `Makefile` is the main interface for interacting with the development environment.

| Command | Description |
| :--- | :--- |
| `make up` | Starts containers (forces recreation). |
| `make down` | Stops and removes containers and networks. |
| `make logs` | Shows logs for the backend container. |
| `make celery-logs` | Shows logs for Celery workers. |
| `make bash` | Opens a bash terminal inside the `backend` container. |
| `make shell` | Opens a Django shell (`shell_plus`) with iPython. |
| `make test` | Runs the full test suite with coverage. |
| `make migrations` | Creates and applies pending migrations. |
| `make messages` | Extracts and compiles translation messages (i18n). |
| `make clean-images` | Removes project Docker images. |

## 🧪 Code Quality & Testing

We maintain strict quality standards using `pre-commit` and several static analysis tools.

### Pre-commit
It is recommended to install the hooks locally to avoid CI failures:
```bash
uv tool install pre-commit
pre-commit install
```

### Running Tests

To run tests inside the Docker container and generate coverage reports:

```bash
make test
```

*The system requires a minimum coverage of 90% to pass CI.*

### Configured Tools

* **Ruff:** Fast linter and formatter.
* **Black & Isort:** Code formatting and import sorting.
* **Mypy:** Static type checking.
* **Bandit & Gitleaks:** Security analysis and secret detection.

## 📂 Project Structure

We follow a structure inspired by DDD (Domain-Driven Design) located in `src/apps/`.

```text
.
├── docker/ # Docker configurations
├── src/
│ ├── apps/
│ │ ├── core/ # Users, Auth, Geo, Base utilities
│ │ └── ideology/ # Business logic: Tests, Axes, Affinity Calculation
│ ├── backend/ # Django project configuration (settings, urls)
│ ├── locale/ # Translation files
│ └── tests/ # Unit and integration tests
└── Makefile # Command orchestration

```

## 📚 API Documentation

In the development environment (`PRODUCTION=False`), interactive documentation is available at:

* **Swagger UI:** `http://localhost:3141/api/schema/swagger-ui/`
* **ReDoc:** `http://localhost:3141/api/schema/redoc/`

## 🌍 Internationalization

The project supports multiple languages (Default: Spanish and English).
To update translations after modifying the code:

```bash
make messages
```

This will automatically execute `makemessages` and `compilemessages` for the `es` locale.

---

**Powered by [Django](https://www.djangoproject.com/)**
5 changes: 4 additions & 1 deletion docker/backend/post_deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ python3 manage.py makemigrations --no-input
python3 manage.py migrate --no-input

# Load fixtures
python3 manage.py loaddata 01_complexities 02_sections 03_axes 04_conditioners 05_ideologies
python3 manage.py loaddata 01_complexities 02_sections 03_axes 04_conditioners 05_ideologies 06_axis_definitions 07_conditioner_definitions 08_geography 09_religions 10_tags 11_associations

# Populate Test Data & Init MinIO (Only in Non-Prod)
if [ "$ENVIRONMENT" != "prod" ] && [ "$ENVIRONMENT" != "production" ]; then
Expand All @@ -26,6 +26,9 @@ if [ "$ENVIRONMENT" != "prod" ] && [ "$ENVIRONMENT" != "production" ]; then
python3 manage.py init_minio
fi

# Load flags
python3 manage.py import_flags

# Collect static files
python3 manage.py collectstatic --no-input

Expand Down
1 change: 1 addition & 0 deletions src/apps/core/admin/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from . import user_admin
from .geo_admin import *
18 changes: 18 additions & 0 deletions src/apps/core/admin/geo_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from core.models import Country, Region
from django.contrib import admin
from modeltranslation.admin import TabbedTranslationAdmin
from unfold.admin import ModelAdmin


@admin.register(Country)
class CountryAdmin(ModelAdmin, TabbedTranslationAdmin):
list_display = ["name", "code2", "uuid"]
search_fields = ["name", "code2"]


@admin.register(Region)
class RegionAdmin(ModelAdmin, TabbedTranslationAdmin):
list_display = ["name", "country", "uuid"]
search_fields = ["name", "country__name"]
list_filter = ["country"]
autocomplete_fields = ["country"]
3 changes: 1 addition & 2 deletions src/apps/core/api/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,10 @@
PasswordResetConfirmSerializer,
)
from .geo_serializers import CountrySerializer, RegionSerializer
from .base_user_serializers import SimpleUserSerializer, PublicUserSerializer
from .user_serializers import (
MeSerializer,
RegisterSerializer,
SimpleUserSerializer,
UserSetPasswordSerializer,
UserVerificationSerializer,
AffinitySerializer,
)
16 changes: 16 additions & 0 deletions src/apps/core/api/serializers/base_user_serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from core.helpers import UUIDModelSerializerMixin
from core.models import User


class SimpleUserSerializer(UUIDModelSerializerMixin):
class Meta:
model = User
fields = ["uuid", "username", "bio", "appearance", "is_public"]
read_only_fields = ["uuid"]


class PublicUserSerializer(UUIDModelSerializerMixin):
class Meta:
model = User
fields = ["uuid", "username", "bio", "is_public"]
read_only_fields = ["uuid", "username", "bio", "is_public"]
4 changes: 2 additions & 2 deletions src/apps/core/api/serializers/geo_serializers.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from cities_light.models import Country, Region
from core.models import Country, Region
from rest_framework import serializers


class CountrySerializer(serializers.ModelSerializer):
class Meta:
model = Country
fields = ["id", "name", "code2", "continent"]
fields = ["id", "name", "code2"]


class RegionSerializer(serializers.ModelSerializer):
Expand Down
78 changes: 4 additions & 74 deletions src/apps/core/api/serializers/user_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,83 +7,12 @@
from core.models import User
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.translation import gettext_lazy as _
from ideology.models import IdeologyAbstractionComplexity, IdeologyAxis, IdeologySection
from rest_framework import serializers
from rest_framework.serializers import ErrorDetail

logger = logging.getLogger(__name__)


class SimpleUserSerializer(UUIDModelSerializerMixin):
class Meta:
model = User
fields = ["uuid", "username", "bio", "appearance", "is_public"]
read_only_fields = ["uuid"]


class PublicUserSerializer(UUIDModelSerializerMixin):
class Meta:
model = User
fields = ["uuid", "username", "bio", "is_public"]
read_only_fields = ["uuid", "username", "bio", "is_public"]


class SimpleAxisSerializer(UUIDModelSerializerMixin):
class Meta:
model = IdeologyAxis
fields = ["uuid", "name", "left_label", "right_label"]


class SimpleSectionSerializer(UUIDModelSerializerMixin):
class Meta:
model = IdeologySection
fields = ["uuid", "name", "icon"]
from .base_user_serializers import SimpleUserSerializer


class SimpleComplexitySerializer(UUIDModelSerializerMixin):
class Meta:
model = IdeologyAbstractionComplexity
fields = ["uuid", "name", "complexity"]


class AnswerDetailSerializer(serializers.Serializer):
value = serializers.IntegerField(allow_null=True)
margin_left = serializers.IntegerField()
margin_right = serializers.IntegerField()
is_indifferent = serializers.BooleanField(default=False)


class AxisBreakdownSerializer(serializers.Serializer):
axis = SimpleAxisSerializer(allow_null=True)
my_answer = AnswerDetailSerializer(source="user_a", allow_null=True)
their_answer = AnswerDetailSerializer(source="user_b", allow_null=True)
affinity = serializers.FloatField(min_value=0.0, max_value=100.0, allow_null=True)


class SectionAffinitySerializer(serializers.Serializer):
section = SimpleSectionSerializer(allow_null=True)
affinity = serializers.FloatField(min_value=0.0, max_value=100.0, allow_null=True)
axes = AxisBreakdownSerializer(many=True)


class ComplexityAffinitySerializer(serializers.Serializer):
complexity = SimpleComplexitySerializer(allow_null=True)
affinity = serializers.FloatField(min_value=0.0, max_value=100.0, allow_null=True)
sections = SectionAffinitySerializer(many=True)


class AffinitySerializer(serializers.Serializer):
target_user = PublicUserSerializer(read_only=True, allow_null=True)
total_affinity = serializers.FloatField(
min_value=0.0,
max_value=100.0,
allow_null=True,
source="total",
help_text=_("Overall affinity percentage. Null if no common axes."),
)
complexities = ComplexityAffinitySerializer(
many=True, help_text=_("Affinity grouped by abstraction level.")
)
logger = logging.getLogger(__name__)


class UserVerificationSerializer(SimpleUserSerializer):
Expand Down Expand Up @@ -116,8 +45,9 @@ class Meta:
"appearance",
"is_public",
"atlas_onboarding_completed",
"is_superuser",
]
read_only_fields = ["is_verified", "email", "auth_provider"]
read_only_fields = ["is_verified", "email", "auth_provider", "is_superuser"]


class RegisterSerializer(UUIDModelSerializerMixin):
Expand Down
5 changes: 0 additions & 5 deletions src/apps/core/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,6 @@
),
path("me/", core_views.MeDetailView.as_view(), name="me"),
path("me/password/", core_views.UserSetPasswordView.as_view(), name="set-password"),
path(
"users/affinity/<str:uuid>/",
core_views.UserAffinityView.as_view(),
name="user-affinity",
),
path(
"geography/countries/",
core_views.CountryListView.as_view(),
Expand Down
5 changes: 4 additions & 1 deletion src/apps/core/api/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@
PasswordResetVerifyTokenView,
)
from .geo_views import CountryListView, RegionListView
from .user_views import MeDetailView, UserSetPasswordView, UserAffinityView
from .user_views import (
MeDetailView,
UserSetPasswordView,
)
2 changes: 1 addition & 1 deletion src/apps/core/api/views/geo_views.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from cities_light.models import Country, Region
from core.api.serializers import CountrySerializer, RegionSerializer
from core.models import Country, Region
from django.utils.translation import gettext_lazy as _
from django_filters import rest_framework as filters
from drf_spectacular.utils import OpenApiParameter, extend_schema
Expand Down
Loading
Loading