API Documentation - bounswe/bounswe2026group4 GitHub Wiki
This document describes the API endpoints currently available in the MVP version of the Local History Story Map project, including authentication, user profiles, stories, interactions, and image uploads.
Table of Contents
Overview
The Local History Story Map API powers a location-based storytelling platform where users can register, authenticate with JWT, manage their profiles, publish and edit stories, browse them in feed or map format, comment on stories, like them, and upload images.
Global Conventions
Base URL
All endpoints are relative to the server root.
Example:
http://localhost:8000
Authentication
Authenticated endpoints require a JWT access token in the Authorization header:
Authorization: Bearer <access_token>
Access and refresh tokens are obtained from POST /auth/login/.
Error Response Format
All error responses follow this normalized structure:
{
"success": false,
"message": "Human-readable error message",
"errors": {
"field_name": ["Detailed error 1", "Detailed error 2"]
}
}
Pagination Format
Paginated endpoints return:
{
"count": 42,
"next": "http://localhost:8000/stories/feed/?page=2",
"previous": null,
"results": []
}
Supported pagination query parameters:
page— default1page_size— default10, maximum100
Authentication
POST /auth/register/
Creates a new user account.
Auth required: No
Request Body
| Field | Type | Required | Constraints |
|---|---|---|---|
email |
string | Yes | Valid email, must be unique (case-insensitive) |
username |
string | Yes | 3–150 chars, must be unique (case-insensitive) |
password |
string | Yes | Min 8 chars, at least 1 uppercase, 1 lowercase, 1 digit |
password_confirmation |
string | Yes | Must match password |
Example Request
curl -X POST http://localhost:8000/auth/register/ \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"username": "aysu_keskin",
"password": "SecurePass1",
"password_confirmation": "SecurePass1"
}'
{
"email": "[email protected]",
"username": "aysu_keskin",
"password": "SecurePass1",
"password_confirmation": "SecurePass1"
}
Success Response — 201 Created
{
"message": "Registration successful. Please verify your email.",
"user": {
"id": 1,
"email": "[email protected]",
"username": "aysu_keskin",
"role": "registered_user",
"is_email_verified": false,
"date_joined": "2026-04-04T10:30:00Z"
}
}
Example Error Responses
Duplicate email:
{
"success": false,
"message": "A user with this email already exists.",
"errors": {
"email": ["A user with this email already exists."]
}
}
Password mismatch:
{
"success": false,
"message": "Passwords do not match.",
"errors": {
"password_confirmation": ["Passwords do not match."]
}
}
Weak password:
{
"success": false,
"message": "Password must be at least 8 characters long.",
"errors": {
"password": [
"Password must be at least 8 characters long.",
"Password must contain at least one uppercase letter."
]
}
}
POST /auth/login/
Authenticates a user and returns JWT access and refresh tokens.
Auth required: No
This endpoint uses the same error message for wrong email, wrong password, and inactive accounts to avoid user enumeration.
Request Body
| Field | Type | Required |
|---|---|---|
email |
string | Yes |
password |
string | Yes |
Example Request
curl -X POST http://localhost:8000/auth/login/ \
-H "Content-Type: application/json" \
-d '{
"email": "[email protected]",
"password": "SecurePass1"
}'
{
"email": "[email protected]",
"password": "SecurePass1"
}
Success Response — 200 OK
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"email": "[email protected]",
"username": "aysu_keskin",
"role": "registered_user"
}
}
JWT Custom Claims
role— user role (registered_useroradmin)username— username
Error Response — 401 Unauthorized
{
"success": false,
"message": "Invalid credentials.",
"errors": {}
}
POST /auth/logout/
Blacklists the provided refresh token.
Auth required: Yes
The access token expires naturally after its lifetime.
Request Body
| Field | Type | Required |
|---|---|---|
refresh |
string | Yes |
Example Request
curl -X POST http://localhost:8000/auth/logout/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}'
Success Response — 204 No Content
Empty response body.
Error Response — 400 Bad Request
{
"success": false,
"message": "Token is invalid or already blacklisted.",
"errors": {
"refresh": "Token is invalid or already blacklisted."
}
}
User Profiles
GET /users/me/
Returns the authenticated user's full profile, including private fields and privacy flags.
Auth required: Yes
If a UserProfile row does not yet exist, it is created on first access.
Example Request
curl http://localhost:8000/users/me/ \
-H "Authorization: Bearer <access_token>"
Success Response — 200 OK
{
"success": true,
"data": {
"id": 1,
"email": "[email protected]",
"username": "aysu_keskin",
"role": "registered_user",
"is_username_public": true,
"is_email_verified": false,
"date_joined": "2026-04-04T10:30:00Z",
"total_points": 0,
"profile": {
"profile_photo": "http://localhost:8000/media/profile_photos/photo.jpg",
"location": "Istanbul, Turkey",
"birth_date": "1998-05-15",
"bio": "History enthusiast from Istanbul.",
"is_location_public": true,
"is_birth_date_public": true,
"is_photo_public": true
}
}
}
Note
If the profile has never been filled, profile may be null on first access.
PATCH /users/me/
Partially updates the authenticated user's profile.
Auth required: Yes
Only the provided fields are updated. Omitted fields remain unchanged. System-managed fields such as email, role, total_points, is_active, is_email_verified, and date_joined are ignored.
Request Body
| Field | Type | Description |
|---|---|---|
username |
string | 3–150 chars, must be unique |
is_username_public |
boolean | Whether username is publicly visible |
profile |
object | Nested profile fields |
Nested profile fields:
| Field | Type | Description |
|---|---|---|
profile_photo |
file (image) | Profile photo upload |
location |
string | Max 255 chars, can be blank |
birth_date |
date | YYYY-MM-DD, nullable |
bio |
string | Can be blank |
is_location_public |
boolean | Privacy flag |
is_birth_date_public |
boolean | Privacy flag |
is_photo_public |
boolean | Privacy flag |
Example Request
curl -X PATCH http://localhost:8000/users/me/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"username": "aysu_new",
"profile": {
"bio": "Exploring the history of Istanbul.",
"location": "Beyoglu, Istanbul",
"is_location_public": false
}
}'
Success Response — 200 OK
{
"success": true,
"data": {
"id": 1,
"email": "[email protected]",
"username": "aysu_new",
"role": "registered_user",
"is_username_public": true,
"is_email_verified": false,
"date_joined": "2026-04-04T10:30:00Z",
"total_points": 0,
"profile": {
"profile_photo": null,
"location": "Beyoglu, Istanbul",
"birth_date": null,
"bio": "Exploring the history of Istanbul.",
"is_location_public": false,
"is_birth_date_public": true,
"is_photo_public": true
}
}
}
Error Response — duplicate username
{
"success": false,
"message": "A user with this username already exists.",
"errors": {
"username": ["A user with this username already exists."]
}
}
GET /users/{user_id}/
Returns the public profile of any active user.
Auth required: No
Privacy flags control visibility. Public responses expose only the birth year, not the full birth date.
Path Parameters
| Param | Type | Description |
|---|---|---|
user_id |
integer | User identifier |
Example Request
curl http://localhost:8000/users/1/
Success Response — 200 OK
{
"id": 1,
"username": "aysu_keskin",
"total_points": 150,
"date_joined": "2026-04-04T10:30:00Z",
"published_story_count": 5,
"profile_photo": "http://localhost:8000/media/profile_photos/photo.jpg",
"location": "Istanbul, Turkey",
"bio": "History enthusiast from Istanbul.",
"birth_year": 1998
}
Example Response When Privacy Flags Hide Fields
{
"id": 1,
"username": null,
"total_points": 150,
"date_joined": "2026-04-04T10:30:00Z",
"published_story_count": 5,
"profile_photo": null,
"location": null,
"bio": "History enthusiast from Istanbul.",
"birth_year": null
}
Error Response
404 Not Found if the user does not exist or is inactive.
Stories
GET /stories/
Lists all published stories.
Auth required: No
Authenticated users additionally receive user_has_liked and user_has_saved.
Example Request
curl http://localhost:8000/stories/
Success Response — 200 OK
{
"count": 25,
"next": "http://localhost:8000/stories/?page=2",
"previous": null,
"results": [
{
"id": 1,
"user": 1,
"contributor_name": "aysu_keskin",
"title": "The Grand Bazaar Through the Ages",
"narrative": "The Grand Bazaar has been the heart of Istanbul's trade since 1461...",
"location_lat": "41.010700",
"location_lng": "28.968000",
"location_name": "Grand Bazaar",
"region": "Fatih, Istanbul",
"time_type": "exact_year",
"year": 1461,
"year_start": null,
"year_end": null,
"status": "published",
"contributor_visible": true,
"like_count": 12,
"save_count": 3,
"user_has_liked": false,
"user_has_saved": false,
"submitted_at": "2026-04-03T14:22:00Z",
"updated_at": "2026-04-03T14:22:00Z"
}
]
}
POST /stories/
Creates a new story.
Auth required: Yes
The authenticated user is automatically set as the author. Validation depends on time_type.
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Max 255 chars |
narrative |
string | Yes | Full story text |
location_lat |
decimal | Yes | Latitude, max 9 digits, 6 decimals |
location_lng |
decimal | Yes | Longitude, max 10 digits, 6 decimals |
location_name |
string | Yes | Human-readable place name |
region |
string | No | City/district |
time_type |
string | Yes | exact_year, approximate_year, decade, or year_range |
year |
integer | Conditional | Required for exact_year, approximate_year, decade |
year_start |
integer | Conditional | Required for year_range |
year_end |
integer | Conditional | Required for year_range |
status |
string | No | draft or published, defaults to published |
contributor_visible |
boolean | No | Defaults to true |
Example Request — exact year
curl -X POST http://localhost:8000/stories/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"title": "The Grand Bazaar Through the Ages",
"narrative": "The Grand Bazaar has been the heart of Istanbul''s trade since 1461...",
"location_lat": "41.010700",
"location_lng": "28.968000",
"location_name": "Grand Bazaar",
"region": "Fatih, Istanbul",
"time_type": "exact_year",
"year": 1461,
"contributor_visible": true
}'
Example Request — year range
{
"title": "The Ottoman Empire's Golden Age",
"narrative": "From Mehmed II's conquest to Suleiman the Magnificent...",
"location_lat": "41.008600",
"location_lng": "28.980100",
"location_name": "Topkapi Palace",
"time_type": "year_range",
"year_start": 1453,
"year_end": 1566
}
Success Response — 201 Created
Returns the full story object in the same shape as GET /stories/{id}/.
Error Response — missing year
{
"success": false,
"message": "year is required for exact_year.",
"errors": {
"year": ["year is required for exact_year."]
}
}
GET /stories/{id}/
Returns a single story with full details, including nested media items.
Auth required: No
Authenticated users additionally receive user_has_liked and user_has_saved.
Example Request
curl http://localhost:8000/stories/1/
Success Response — 200 OK
{
"id": 1,
"user": 1,
"contributor_name": "aysu_keskin",
"title": "The Grand Bazaar Through the Ages",
"narrative": "The Grand Bazaar has been the heart of Istanbul's trade since 1461...",
"location_lat": "41.010700",
"location_lng": "28.968000",
"location_name": "Grand Bazaar",
"region": "Fatih, Istanbul",
"time_type": "exact_year",
"year": 1461,
"year_start": null,
"year_end": null,
"status": "published",
"contributor_visible": true,
"like_count": 12,
"save_count": 3,
"user_has_liked": true,
"user_has_saved": false,
"submitted_at": "2026-04-03T14:22:00Z",
"updated_at": "2026-04-03T14:22:00Z",
"media_items": [
{
"id": 1,
"url": "http://localhost:8000/media/stories/2026/04/bazaar.jpg",
"media_type": "image",
"order": 0
}
]
}
Error Response
404 Not Found
PATCH /stories/{id}/
Partially updates a story.
Auth required: Yes — owner or admin only
The status field cannot be changed to removed through this endpoint.
Example Request
curl -X PATCH http://localhost:8000/stories/1/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"title": "Updated Title",
"narrative": "Updated story content..."
}'
Success Response — 200 OK
Returns the updated story object.
Error Response — 403 Forbidden
{
"success": false,
"message": "You do not have permission to access this resource.",
"errors": {}
}
DELETE /stories/{id}/
Permanently deletes a story.
Auth required: Yes — owner or admin only
Cascade deletion removes related media items, likes, bookmarks, and comments.
Example Request
curl -X DELETE http://localhost:8000/stories/1/ \
-H "Authorization: Bearer <access_token>"
Success Response — 204 No Content
Empty response body.
Possible Errors
403 Forbidden404 Not Found
GET /stories/feed/
Returns a compact paginated feed of published stories.
Auth required: No
Authenticated users additionally receive user_has_liked and user_has_saved.
Query Parameters
| Param | Type | Default | Description |
|---|---|---|---|
sort_by |
string | recent |
recent or popular |
year_from |
integer | — | Stories from this year onward |
year_to |
integer | — | Stories up to and including this year |
location |
string | — | Case-insensitive substring match on location_name |
page |
integer | 1 |
Page number |
page_size |
integer | 10 |
Results per page, max 100 |
Example Request
curl "http://localhost:8000/stories/feed/?sort_by=recent&year_from=1900&year_to=2000&location=istanbul"
Success Response — 200 OK
{
"count": 8,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"title": "The Grand Bazaar Through the Ages",
"location_name": "Grand Bazaar",
"location_lat": "41.010700",
"location_lng": "28.968000",
"time_type": "exact_year",
"year": 1961,
"year_start": null,
"year_end": null,
"status": "published",
"contributor_name": "aysu_keskin",
"preview_text": "The Grand Bazaar has been the heart of Istanbul's trade since the mid-20th century restoration period when...",
"user_has_liked": false,
"user_has_saved": true,
"submitted_at": "2026-04-03T14:22:00Z"
}
]
}
Error Response — invalid year range
{
"success": false,
"message": "year_to must be greater than or equal to year_from.",
"errors": {
"year_to": ["year_to must be greater than or equal to year_from."]
}
}
GET /stories/map/
Returns paginated story data optimized for map pin rendering.
Auth required: No
This endpoint supports the same filtering as the feed endpoint except sort_by.
Example Request
curl "http://localhost:8000/stories/map/?year_from=1400&year_to=1600&location=istanbul"
Success Response — 200 OK
{
"count": 15,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"title": "The Grand Bazaar Through the Ages",
"location_name": "Grand Bazaar",
"location_lat": "41.010700",
"location_lng": "28.968000",
"time_type": "exact_year",
"year": 1461,
"year_start": null,
"year_end": null
}
]
}
GET /stories/search/
Searches published stories by title and location name.
Auth required: No
Authenticated users additionally receive user_has_liked and user_has_saved.
Query Parameters
| Param | Type | Required | Description |
|---|---|---|---|
q |
string | Yes | Search query, at least 1 non-whitespace character |
Example Request
curl "http://localhost:8000/stories/search/?q=bazaar"
Success Response — 200 OK
Returns the same structure as the feed endpoint.
Error Response — missing query
{
"success": false,
"message": "This field is required.",
"errors": {
"q": ["This field is required."]
}
}
Interactions
GET /stories/{story_id}/comments/
Returns a paginated list of comments for a published story, sorted oldest first.
Auth required: No
Example Request
curl http://localhost:8000/stories/1/comments/
Success Response — 200 OK
{
"count": 3,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"story_id": 1,
"author_username": "aysu_keskin",
"text": "Fascinating story! I visited the Grand Bazaar last summer.",
"is_anonymized": false,
"created_at": "2026-04-03T15:00:00Z"
},
{
"id": 2,
"story_id": 1,
"author_username": null,
"text": "This comment was left by a deleted user.",
"is_anonymized": true,
"created_at": "2026-04-03T16:00:00Z"
}
]
}
Error Response
404 Not Found if the story does not exist or is not published.
POST /stories/{story_id}/comments/
Adds a comment to a published story.
Auth required: Yes
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
text |
string | Yes | Comment text, cannot be blank |
Example Request
curl -X POST http://localhost:8000/stories/1/comments/ \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{
"text": "What a wonderful piece of history!"
}'
Success Response — 201 Created
{
"message": "Comment added successfully.",
"comment": {
"id": 4,
"story_id": 1,
"author_username": "aysu_keskin",
"text": "What a wonderful piece of history!",
"is_anonymized": false,
"created_at": "2026-04-04T11:30:00Z"
}
}
Possible Errors
401 Unauthorized404 Not Found
DELETE /comments/{comment_id}/
Permanently deletes a comment.
Auth required: Yes — comment author or admin only
Example Request
curl -X DELETE http://localhost:8000/comments/4/ \
-H "Authorization: Bearer <access_token>"
Success Response — 204 No Content
Empty response body.
Possible Errors
403 Forbidden404 Not Found
POST /stories/{story_id}/like/
Likes a published story.
Auth required: Yes
Each user may like a story only once.
Example Request
curl -X POST http://localhost:8000/stories/1/like/ \
-H "Authorization: Bearer <access_token>"
Success Response — 201 Created
{
"message": "Story liked successfully.",
"like": {
"id": 7,
"story_id": 1,
"created_at": "2026-04-04T11:35:00Z"
}
}
Error Response — already liked
{
"success": false,
"message": "You have already liked this story.",
"errors": {
"detail": "You have already liked this story."
}
}
Possible Errors
400 Bad Request404 Not Found
DELETE /stories/{story_id}/like/
Removes the authenticated user's like from a story.
Auth required: Yes
Example Request
curl -X DELETE http://localhost:8000/stories/1/like/ \
-H "Authorization: Bearer <access_token>"
Success Response — 204 No Content
Empty response body.
Error Response
404 Not Found if the story does not exist, is not published, or the user has not liked it.
Media
POST /stories/{story_id}/images/
Uploads an image to a story.
Auth required: Yes — story owner or admin only
The file is validated using both Pillow and python-magic. Only JPEG and PNG are accepted. Maximum file size is 2 MB. Removed stories cannot receive uploads.
Request Format
Content-Type: multipart/form-data
| Field | Type | Required | Constraints |
|---|---|---|---|
file |
file | Yes | JPEG or PNG, max 2 MB |
Example Request
curl -X POST http://localhost:8000/stories/1/images/ \
-H "Authorization: Bearer <access_token>" \
-F "file=@bazaar_entrance.jpg"
Success Response — 201 Created
{
"message": "Image uploaded successfully.",
"image": {
"id": 1,
"url": "http://localhost:8000/media/stories/2026/04/bazaar_entrance.jpg",
"file_size": 524288,
"original_filename": "bazaar_entrance.jpg",
"uploaded_at": "2026-04-04T12:00:00Z"
}
}
Error Response — invalid file type
{
"success": false,
"message": "Unsupported file type. Only JPEG and PNG images are accepted.",
"errors": {
"file": ["Unsupported file type. Only JPEG and PNG images are accepted."]
}
}
Error Response — file too large
{
"success": false,
"message": "File size must not exceed 2 MB.",
"errors": {
"file": ["File size must not exceed 2 MB."]
}
}
Possible Errors
400 Bad Request403 Forbidden
Quick Endpoint Summary
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/register/ |
No | Register a new user |
| POST | /auth/login/ |
No | Log in and get JWT tokens |
| POST | /auth/logout/ |
Yes | Blacklist refresh token |
| GET | /users/me/ |
Yes | Get current user's full profile |
| PATCH | /users/me/ |
Yes | Update current user's profile |
| GET | /users/{user_id}/ |
No | Get public profile of a user |
| GET | /stories/ |
No | List published stories |
| POST | /stories/ |
Yes | Create a story |
| GET | /stories/{id}/ |
No | Get story details |
| PATCH | /stories/{id}/ |
Yes | Update a story |
| DELETE | /stories/{id}/ |
Yes | Delete a story |
| GET | /stories/feed/ |
No | Get compact feed cards |
| GET | /stories/map/ |
No | Get story map pins |
| GET | /stories/search/ |
No | Search stories |
| GET | /stories/{story_id}/comments/ |
No | List comments for a story |
| POST | /stories/{story_id}/comments/ |
Yes | Add a comment |
| DELETE | /comments/{comment_id}/ |
Yes | Delete a comment |
| POST | /stories/{story_id}/like/ |
Yes | Like a story |
| DELETE | /stories/{story_id}/like/ |
Yes | Remove like |
| POST | /stories/{story_id}/images/ |
Yes | Upload a story image |
Notes
This documentation reflects the endpoint definitions explicitly present in the current MVP project. Additional endpoints may be added in the post-MVP phase.