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 — default 1
  • page_size — default 10, maximum 100

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_user or admin)
  • 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 Forbidden
  • 404 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 Unauthorized
  • 404 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 Forbidden
  • 404 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 Request
  • 404 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 Request
  • 403 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.