-[](https://www.buymeacoffee.com/seanmorley15)
+
-- **[Documentation](https://adventurelog.app)**
-- **[Demo](https://demo.adventurelog.app)**
-- **[Join the AdventureLog Community Discord Server](https://discord.gg/wRbQ9Egr8C)**
+
# Table of Contents
-- [Installation](#installation)
- - [Docker 🐋](#docker-)
- - [Prerequisites](#prerequisites)
- - [Getting Started](#getting-started)
- - [Configuration](#configuration)
- - [Frontend Container (web)](#frontend-container-web)
- - [Backend Container (server)](#backend-container-server)
- - [Proxy Container (nginx) Configuration](#proxy-container-nginx-configuration)
- - [Running the Containers](#running-the-containers)
-- [Screenshots 🖼️](#screenshots)
-- [About AdventureLog](#about-adventurelog)
-- [Attribution](#attribution)
+- [About the Project](#-about-the-project)
+ - [Screenshots](#-screenshots)
+ - [Tech Stack](#-tech-stack)
+ - [Features](#-features)
+- [Roadmap](#-roadmap)
+- [Contributing](#-contributing)
+- [License](#-license)
+- [Contact](#-contact)
+- [Acknowledgements](#-acknowledgements)
-# Installation
+
-# Docker 🐋
+## ⭐ About the Project
-Docker is the preferred way to run AdventureLog on your local machine. It is a lightweight containerization technology that allows you to run applications in isolated environments called containers.
-**Note**: This guide mainly focuses on installation with a linux based host machine, but the steps are similar for other operating systems.
+Starting from a simple idea of tracking travel locations (called adventures), AdventureLog has grown into a full-fledged travel companion. With AdventureLog, you can log your adventures, keep track of where you've been on the world map, plan your next trip collaboratively, and share your experiences with friends and family.
-## Prerequisites
+AdventureLog was created to solve a problem: the lack of a modern, open-source, user-friendly travel companion. Many existing travel apps are either too complex, too expensive, or too closed-off to be useful for the average traveler. AdventureLog aims to be the opposite: simple, beautiful, and open to everyone.
-- Docker installed on your machine/server. You can learn how to download it [here](https://docs.docker.com/engine/install/).
+
-## Getting Started
+### 📷 Screenshots
-Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine:
+
+
+
Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
+
+
Shows specific details about an adventure, including the name, date, location, description, and rating.
+
+
+
View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map
+
+
Displays a summary of your adventures, including your world travel stats.
+
+
Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
+
+
Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
+
+
Displays the regions for a specific country, includes a map view to visually select regions.
+
-```bash
-wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
-```
+
-## Configuration
+### 🚀 Tech Stack
-Here is a summary of the configuration options available in the `docker-compose.yml` file:
+
+ Client
+
+
+
-### Frontend Container (web)
+### 🎯 Features
-| Name | Required | Description | Default Value |
-| ------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
-| `PUBLIC_SERVER_URL` | Yes | What the frontend SSR server uses to connect to the backend. | http://server:8000 |
-| `ORIGIN` | Sometimes | Not needed if using HTTPS. If not, set it to the domain of what you will acess the app from. | http://localhost:8015 |
-| `BODY_SIZE_LIMIT` | Yes | Used to set the maximum upload size to the server. Should be changed to prevent someone from uploading too much! Custom values must be set in **kiliobytes**. | Infinity |
+- **Track Your Adventures** 🌍: Log your adventures and keep track of where you've been on the world map.
+ - Adventures can store a variety of information, including the location, date, and description.
+ - Adventures can be sorted into custom categories for easy organization.
+ - Adventures can be marked as private or public, allowing you to share your adventures with friends and family.
+ - Keep track of the countries and regions you've visited with the world travel book.
+- **Plan Your Next Trip** 📃: Take the guesswork out of planning your next adventure with an easy-to-use itinerary planner.
+ - Itineraries can be created for any number of days and can include multiple destinations.
+ - Itineraries include many planning features like flight information, notes, checklists, and links to external resources.
+ - Itineraries can be shared with friends and family for collaborative planning.
+- **Share Your Experiences** 📸: Share your adventures with friends and family and collaborate on trips together.
+ - Adventures and itineraries can be shared via a public link or directly with other AdventureLog users.
+ - Collaborators can view and edit shared itineraries (collections), making planning a breeze.
-### Backend Container (server)
+
-| Name | Required | Description | Default Value |
-| ----------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
-| `PGHOST` | Yes | Databse host. | db |
-| `PGDATABASE` | Yes | Database. | database |
-| `PGUSER` | Yes | Database user. | adventure |
-| `PGPASSWORD` | Yes | Database password. | changeme123 |
-| `DJANGO_ADMIN_USERNAME` | Yes | Default username. | admin |
-| `DJANGO_ADMIN_PASSWORD` | Yes | Default password, change after inital login. | admin |
-| `DJANGO_ADMIN_EMAIL` | Yes | Default user's email. | admin@example.com |
-| `PUBLIC_URL` | Yes | This needs to match the outward port of the server and be accessible from where the app is used. It is used for the creation of image urls. | http://localhost:8016 |
-| `CSRF_TRUSTED_ORIGINS` | Yes | Need to be changed to the orgins where you use your backend server and frontend. These values are comma seperated. | http://localhost:8016 |
-| `FRONTEND_URL` | Yes | This is the publicly accessible url to the **frontend** container. This link should be accessible for all users. Used for email generation. | http://localhost:8015 |
+## 🧭 Roadmap
-## Running the Containers
+The AdventureLog Roadmap can be found [here](https://github.com/users/seanmorley15/projects/5)
-To start the containers, run the following command:
+
-```bash
-docker compose up -d
-```
+## 👋 Contributing
-Enjoy AdventureLog! 🎉
+
+
+
-# Screenshots
+Contributions are always welcome!
-
-Displays the adventures you have visited and the ones you plan to embark on. You can also filter and sort the adventures.
+See `contributing.md` for ways to get started.
-
-Shows specific details about an adventure, including the name, date, location, description, and rating.
+
-
+## 📃 License
-
-View all of your adventures on a map, with the ability to filter by visit status and add new ones by click on the map.
+Distributed under the GNU General Public License v3.0. See `LICENSE` for more information.
-
-Displays a summary of your adventures, including your world travel stats.
+
-
-Plan your adventures and travel itinerary with a list of activities and a map view. View your trip in a variety of ways, including an itinerary list, a map view, and a calendar view.
+## 🤝 Contact
-
-Lists all the countries you have visited and plan to visit, with the ability to filter by visit status.
+Sean Morley - [website](https://seanmorley.com)
-
-Displays the regions for a specific country, includes a map view to visually select regions.
+Hi! I'm Sean, the creator of AdventureLog. I'm a college student and software developer with a passion for travel and adventure. I created AdventureLog to help people like me document their adventures and plan new ones effortlessly. As a student, I am always looking for more opportunities to learn and grow, so feel free to reach out via the contact on my website if you would like to collaborate or chat!
-# About AdventureLog
+
-AdventureLog is a Svelte Kit and Django application that utilizes a PostgreSQL database. Users can log the adventures they have experienced, as well as plan future ones. Key features include:
-
-- Logging past adventures with fields like name, date, location, description, and rating.
-- Planning future adventures with similar fields.
-- Tagging different activity types for better organization.
-- Viewing countries, regions, and marking visited regions.
-
-AdventureLog aims to be your ultimate travel companion, helping you document your adventures and plan new ones effortlessly.
-
-AdventureLog is licensed under the GNU General Public License v3.0.
-
-
-
-# Attribution
+## 💎 Acknowledgements
- Logo Design by [nordtechtiger](https://github.com/nordtechtiger)
- WorldTravel Dataset [dr5hn/countries-states-cities-database](https://github.com/dr5hn/countries-states-cities-database)
diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh
index 48adee3..50e034a 100644
--- a/backend/entrypoint.sh
+++ b/backend/entrypoint.sh
@@ -20,21 +20,45 @@ done
python manage.py migrate
# Create superuser if environment variables are set and there are no users present at all.
-if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ]; then
+if [ -n "$DJANGO_ADMIN_USERNAME" ] && [ -n "$DJANGO_ADMIN_PASSWORD" ] && [ -n "$DJANGO_ADMIN_EMAIL" ]; then
echo "Creating superuser..."
python manage.py shell << EOF
from django.contrib.auth import get_user_model
+from allauth.account.models import EmailAddress
+
User = get_user_model()
-if User.objects.count() == 0:
- User.objects.create_superuser('$DJANGO_ADMIN_USERNAME', '$DJANGO_ADMIN_EMAIL', '$DJANGO_ADMIN_PASSWORD')
+
+# Check if the user already exists
+if not User.objects.filter(username='$DJANGO_ADMIN_USERNAME').exists():
+ # Create the superuser
+ superuser = User.objects.create_superuser(
+ username='$DJANGO_ADMIN_USERNAME',
+ email='$DJANGO_ADMIN_EMAIL',
+ password='$DJANGO_ADMIN_PASSWORD'
+ )
print("Superuser created successfully.")
+
+ # Create the EmailAddress object for AllAuth
+ EmailAddress.objects.create(
+ user=superuser,
+ email='$DJANGO_ADMIN_EMAIL',
+ verified=True,
+ primary=True
+ )
+ print("EmailAddress object created successfully for AllAuth.")
else:
print("Superuser already exists.")
EOF
fi
+
+# Sync the countries and world travel regions
# Sync the countries and world travel regions
python manage.py download-countries
+if [ $? -eq 137 ]; then
+ >&2 echo "WARNING: The download-countries command was interrupted. This is likely due to lack of memory allocated to the container or the host. Please try again with more memory."
+ exit 1
+fi
cat /code/adventurelog.txt
diff --git a/backend/nginx.conf b/backend/nginx.conf
index fafff04..b4bad7d 100644
--- a/backend/nginx.conf
+++ b/backend/nginx.conf
@@ -38,4 +38,4 @@ http {
alias /code/media/; # Serve media files directly
}
}
-}
+}
\ No newline at end of file
diff --git a/backend/server/adventures/admin.py b/backend/server/adventures/admin.py
index be1793b..5c39301 100644
--- a/backend/server/adventures/admin.py
+++ b/backend/server/adventures/admin.py
@@ -2,7 +2,7 @@ import os
from django.contrib import admin
from django.utils.html import mark_safe
from .models import Adventure, Checklist, ChecklistItem, Collection, Transportation, Note, AdventureImage, Visit, Category
-from worldtravel.models import Country, Region, VisitedRegion
+from worldtravel.models import Country, Region, VisitedRegion, City, VisitedCity
from allauth.account.decorators import secure_admin_login
admin.autodiscover()
@@ -51,6 +51,16 @@ class RegionAdmin(admin.ModelAdmin):
number_of_visits.short_description = 'Number of Visits'
+class CityAdmin(admin.ModelAdmin):
+ list_display = ('name', 'region', 'country')
+ list_filter = ('region', 'region__country')
+ search_fields = ('name', 'region__name', 'region__country__name')
+
+ def country(self, obj):
+ return obj.region.country.name
+
+ country.short_description = 'Country'
+
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from users.models import CustomUser
@@ -127,6 +137,8 @@ admin.site.register(Checklist)
admin.site.register(ChecklistItem)
admin.site.register(AdventureImage, AdventureImageAdmin)
admin.site.register(Category, CategoryAdmin)
+admin.site.register(City, CityAdmin)
+admin.site.register(VisitedCity)
admin.site.site_header = 'AdventureLog Admin'
admin.site.site_title = 'AdventureLog Admin Site'
diff --git a/backend/server/adventures/middleware.py b/backend/server/adventures/middleware.py
index 3cd9713..550e581 100644
--- a/backend/server/adventures/middleware.py
+++ b/backend/server/adventures/middleware.py
@@ -20,4 +20,23 @@ class PrintCookiesMiddleware:
def __call__(self, request):
print(request.COOKIES)
response = self.get_response(request)
- return response
\ No newline at end of file
+ return response
+
+# middlewares.py
+
+import os
+from django.http import HttpRequest
+
+class OverrideHostMiddleware:
+ def __init__(self, get_response):
+ self.get_response = get_response
+
+ def __call__(self, request: HttpRequest):
+ # Override the host with the PUBLIC_URL environment variable
+ public_url = os.getenv('PUBLIC_URL', None)
+ if public_url:
+ # Split the public URL to extract the host and port (if available)
+ host = public_url.split("//")[-1].split("/")[0]
+ request.META['HTTP_HOST'] = host # Override the HTTP_HOST header
+ response = self.get_response(request)
+ return response
diff --git a/backend/server/adventures/views.py b/backend/server/adventures/views.py
index 3ee3e79..1ce81f5 100644
--- a/backend/server/adventures/views.py
+++ b/backend/server/adventures/views.py
@@ -8,7 +8,7 @@ from django.db.models.functions import Lower
from rest_framework.response import Response
from .models import Adventure, Checklist, Collection, Transportation, Note, AdventureImage, Category
from django.core.exceptions import PermissionDenied
-from worldtravel.models import VisitedRegion, Region, Country
+from worldtravel.models import VisitedCity, VisitedRegion, Region, Country, City
from .serializers import AdventureImageSerializer, AdventureSerializer, CategorySerializer, CollectionSerializer, NoteSerializer, TransportationSerializer, ChecklistSerializer
from rest_framework.permissions import IsAuthenticated
from django.db.models import Q
@@ -20,6 +20,7 @@ from django.contrib.auth import get_user_model
from icalendar import Calendar, Event, vText, vCalAddress
from django.http import HttpResponse
from datetime import datetime
+from django.db.models import Min
User = get_user_model()
@@ -44,17 +45,25 @@ class AdventureViewSet(viewsets.ModelViewSet):
order_direction = self.request.query_params.get('order_direction', 'asc')
include_collections = self.request.query_params.get('include_collections', 'true')
- valid_order_by = ['name', 'type', 'start_date', 'rating', 'updated_at']
+ valid_order_by = ['name', 'type', 'date', 'rating', 'updated_at']
if order_by not in valid_order_by:
order_by = 'name'
if order_direction not in ['asc', 'desc']:
order_direction = 'asc'
+ if order_by == 'date':
+ # order by the earliest visit object associated with the adventure
+ queryset = queryset.annotate(earliest_visit=Min('visits__start_date'))
+ queryset = queryset.filter(earliest_visit__isnull=False)
+ ordering = 'earliest_visit'
# Apply case-insensitive sorting for the 'name' field
- if order_by == 'name':
+ elif order_by == 'name':
queryset = queryset.annotate(lower_name=Lower('name'))
ordering = 'lower_name'
+ elif order_by == 'rating':
+ queryset = queryset.filter(rating__isnull=False)
+ ordering = 'rating'
else:
ordering = order_by
@@ -1150,41 +1159,48 @@ class ReverseGeocodeViewSet(viewsets.ViewSet):
Returns a dictionary containing the region name, country name, and ISO code if found.
"""
iso_code = None
- town = None
- city = None
- county = None
+ town_city_or_county = None
display_name = None
country_code = None
+ city = None
+
+ # town = None
+ # city = None
+ # county = None
+
if 'address' in data.keys():
keys = data['address'].keys()
for key in keys:
if key.find("ISO") != -1:
iso_code = data['address'][key]
if 'town' in keys:
- town = data['address']['town']
+ town_city_or_county = data['address']['town']
if 'county' in keys:
- county = data['address']['county']
+ town_city_or_county = data['address']['county']
if 'city' in keys:
- city = data['address']['city']
+ town_city_or_county = data['address']['city']
if not iso_code:
return {"error": "No region found"}
+
region = Region.objects.filter(id=iso_code).first()
visited_region = VisitedRegion.objects.filter(region=region, user_id=self.request.user).first()
- is_visited = False
+
+ region_visited = False
+ city_visited = False
country_code = iso_code[:2]
if region:
- if city:
- display_name = f"{city}, {region.name}, {country_code}"
- elif town:
- display_name = f"{town}, {region.name}, {country_code}"
- elif county:
- display_name = f"{county}, {region.name}, {country_code}"
+ if town_city_or_county:
+ display_name = f"{town_city_or_county}, {region.name}, {country_code}"
+ city = City.objects.filter(name__contains=town_city_or_county, region=region).first()
+ visited_city = VisitedCity.objects.filter(city=city, user_id=self.request.user).first()
if visited_region:
- is_visited = True
+ region_visited = True
+ if visited_city:
+ city_visited = True
if region:
- return {"id": iso_code, "region": region.name, "country": region.country.name, "is_visited": is_visited, "display_name": display_name}
+ return {"region_id": iso_code, "region": region.name, "country": region.country.name, "region_visited": region_visited, "display_name": display_name, "city": city.name if city else None, "city_id": city.id if city else None, "city_visited": city_visited}
return {"error": "No region found"}
@action(detail=False, methods=['get'])
diff --git a/backend/server/main/settings.py b/backend/server/main/settings.py
index 5acecb7..06bd33b 100644
--- a/backend/server/main/settings.py
+++ b/backend/server/main/settings.py
@@ -42,6 +42,7 @@ INSTALLED_APPS = (
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.sites',
+ # "allauth_ui",
'rest_framework',
'rest_framework.authtoken',
'allauth',
@@ -49,8 +50,8 @@ INSTALLED_APPS = (
'allauth.mfa',
'allauth.headless',
'allauth.socialaccount',
- # "widget_tweaks",
- # "slippers",
+ 'allauth.socialaccount.providers.github',
+ 'allauth.socialaccount.providers.openid_connect',
'drf_yasg',
'corsheaders',
'adventures',
@@ -58,6 +59,9 @@ INSTALLED_APPS = (
'users',
'integrations',
'django.contrib.gis',
+ # 'widget_tweaks',
+ # 'slippers',
+
)
MIDDLEWARE = (
@@ -65,6 +69,7 @@ MIDDLEWARE = (
'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
+ 'adventures.middleware.OverrideHostMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
@@ -75,6 +80,8 @@ MIDDLEWARE = (
# disable verifications for new users
ACCOUNT_EMAIL_VERIFICATION = 'none'
+ALLAUTH_UI_THEME = "night"
+
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
@@ -120,7 +127,7 @@ USE_L10N = True
USE_TZ = True
-
+SESSION_COOKIE_SAMESITE = None
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.7/howto/static-files/
@@ -143,6 +150,7 @@ STORAGES = {
}
}
+SILENCED_SYSTEM_CHECKS = ["slippers.E001"]
TEMPLATES = [
{
@@ -175,6 +183,11 @@ SESSION_SAVE_EVERY_REQUEST = True
FRONTEND_URL = getenv('FRONTEND_URL', 'http://localhost:3000')
+# Set login redirect URL to the frontend
+LOGIN_REDIRECT_URL = FRONTEND_URL
+
+SOCIALACCOUNT_LOGIN_ON_GET = True
+
HEADLESS_FRONTEND_URLS = {
"account_confirm_email": f"{FRONTEND_URL}/user/verify-email/{{key}}",
"account_reset_password": f"{FRONTEND_URL}/user/reset-password",
@@ -263,4 +276,4 @@ LOGGING = {
},
}
# https://github.com/dr5hn/countries-states-cities-database/tags
-COUNTRY_REGION_JSON_VERSION = 'v2.4'
\ No newline at end of file
+COUNTRY_REGION_JSON_VERSION = 'v2.5'
\ No newline at end of file
diff --git a/backend/server/main/urls.py b/backend/server/main/urls.py
index ab1e084..571946e 100644
--- a/backend/server/main/urls.py
+++ b/backend/server/main/urls.py
@@ -3,8 +3,8 @@ from django.contrib import admin
from django.views.generic import RedirectView, TemplateView
from django.conf import settings
from django.conf.urls.static import static
-from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView
-from .views import get_csrf_token
+from users.views import IsRegistrationDisabled, PublicUserListView, PublicUserDetailView, UserMetadataView, UpdateUserMetadataView, EnabledSocialProvidersView
+from .views import get_csrf_token, get_public_url
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
@@ -27,7 +27,10 @@ urlpatterns = [
path('auth/user-metadata/', UserMetadataView.as_view(), name='user-metadata'),
+ path('auth/social-providers/', EnabledSocialProvidersView.as_view(), name='enabled-social-providers'),
+
path('csrf/', get_csrf_token, name='get_csrf_token'),
+ path('public-url/', get_public_url, name='get_public_url'),
path('', TemplateView.as_view(template_name='home.html')),
diff --git a/backend/server/main/views.py b/backend/server/main/views.py
index 7a7507d..f21082b 100644
--- a/backend/server/main/views.py
+++ b/backend/server/main/views.py
@@ -1,6 +1,10 @@
from django.http import JsonResponse
from django.middleware.csrf import get_token
+from os import getenv
def get_csrf_token(request):
csrf_token = get_token(request)
return JsonResponse({'csrfToken': csrf_token})
+
+def get_public_url(request):
+ return JsonResponse({'PUBLIC_URL': getenv('PUBLIC_URL')})
\ No newline at end of file
diff --git a/backend/server/requirements.txt b/backend/server/requirements.txt
index bae189f..4e6b0aa 100644
--- a/backend/server/requirements.txt
+++ b/backend/server/requirements.txt
@@ -13,8 +13,10 @@ django-geojson
setuptools
gunicorn==23.0.0
qrcode==8.0
-# slippers==0.6.2
-# django-allauth-ui==1.5.1
-# django-widget-tweaks==1.5.0
+slippers==0.6.2
+django-allauth-ui==1.5.1
+django-widget-tweaks==1.5.0
django-ical==1.9.2
-icalendar==6.1.0
\ No newline at end of file
+icalendar==6.1.0
+ijson==3.3.0
+tqdm==4.67.1
\ No newline at end of file
diff --git a/backend/server/users/serializers.py b/backend/server/users/serializers.py
index b85608c..0e0828f 100644
--- a/backend/server/users/serializers.py
+++ b/backend/server/users/serializers.py
@@ -36,7 +36,7 @@ import os
class UserDetailsSerializer(serializers.ModelSerializer):
"""
- User model w/o password
+ User model without exposing the password.
"""
@staticmethod
@@ -49,8 +49,8 @@ class UserDetailsSerializer(serializers.ModelSerializer):
return username
class Meta:
+ model = CustomUser
extra_fields = ['profile_pic', 'uuid', 'public_profile']
- profile_pic = serializers.ImageField(required=False)
if hasattr(UserModel, 'USERNAME_FIELD'):
extra_fields.append(UserModel.USERNAME_FIELD)
@@ -64,19 +64,14 @@ class UserDetailsSerializer(serializers.ModelSerializer):
extra_fields.append('date_joined')
if hasattr(UserModel, 'is_staff'):
extra_fields.append('is_staff')
- if hasattr(UserModel, 'public_profile'):
- extra_fields.append('public_profile')
- class Meta:
- model = CustomUser
- fields = ('profile_pic', 'uuid', 'public_profile', 'email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
-
- model = UserModel
- fields = ('pk', *extra_fields)
+ fields = ['pk', *extra_fields]
read_only_fields = ('email', 'date_joined', 'is_staff', 'is_superuser', 'is_active', 'pk')
def handle_public_profile_change(self, instance, validated_data):
- """Remove user from `shared_with` if public profile is set to False."""
+ """
+ Remove user from `shared_with` if public profile is set to False.
+ """
if 'public_profile' in validated_data and not validated_data['public_profile']:
for collection in Collection.objects.filter(shared_with=instance):
collection.shared_with.remove(instance)
@@ -91,20 +86,37 @@ class UserDetailsSerializer(serializers.ModelSerializer):
class CustomUserDetailsSerializer(UserDetailsSerializer):
+ """
+ Custom serializer to add additional fields and logic for the user details.
+ """
+ has_password = serializers.SerializerMethodField()
class Meta(UserDetailsSerializer.Meta):
model = CustomUser
- fields = UserDetailsSerializer.Meta.fields + ('profile_pic', 'uuid', 'public_profile')
- read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid',)
+ fields = UserDetailsSerializer.Meta.fields + ['profile_pic', 'uuid', 'public_profile', 'has_password']
+ read_only_fields = UserDetailsSerializer.Meta.read_only_fields + ('uuid', 'has_password')
+
+ @staticmethod
+ def get_has_password(instance):
+ """
+ Computes whether the user has a usable password set.
+ """
+ return instance.has_usable_password()
def to_representation(self, instance):
+ """
+ Customizes the serialized output to modify `profile_pic` URL and add computed fields.
+ """
representation = super().to_representation(instance)
+
+ # Construct profile picture URL if it exists
if instance.profile_pic:
public_url = os.environ.get('PUBLIC_URL', 'http://127.0.0.1:8000').rstrip('/')
- #print(public_url)
- # remove any ' from the url
- public_url = public_url.replace("'", "")
+ public_url = public_url.replace("'", "") # Sanitize URL
representation['profile_pic'] = f"{public_url}/media/{instance.profile_pic.name}"
- del representation['pk'] # remove the pk field from the response
+
+ # Remove `pk` field from the response
+ representation.pop('pk', None)
+
return representation
diff --git a/backend/server/users/views.py b/backend/server/users/views.py
index 109d04b..b03760e 100644
--- a/backend/server/users/views.py
+++ b/backend/server/users/views.py
@@ -1,3 +1,4 @@
+from os import getenv
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
@@ -9,6 +10,7 @@ from django.conf import settings
from django.shortcuts import get_object_or_404
from django.contrib.auth import get_user_model
from .serializers import CustomUserDetailsSerializer as PublicUserSerializer
+from allauth.socialaccount.models import SocialApp
User = get_user_model()
@@ -120,4 +122,31 @@ class UpdateUserMetadataView(APIView):
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)
- return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
\ No newline at end of file
+ return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
+
+class EnabledSocialProvidersView(APIView):
+ """
+ Get enabled social providers for social authentication. This is used to determine which buttons to show on the frontend. Also returns a URL for each to start the authentication flow.
+ """
+
+ @swagger_auto_schema(
+ responses={
+ 200: openapi.Response('Enabled social providers'),
+ 400: 'Bad Request'
+ },
+ operation_description="Get enabled social providers."
+ )
+ def get(self, request):
+ social_providers = SocialApp.objects.filter(sites=settings.SITE_ID)
+ providers = []
+ for provider in social_providers:
+ if provider.provider == 'openid_connect':
+ new_provider = f'oidc/{provider.client_id}'
+ else:
+ new_provider = provider.provider
+ providers.append({
+ 'provider': provider.provider,
+ 'url': f"{getenv('PUBLIC_URL')}/accounts/{new_provider}/login/",
+ 'name': provider.name
+ })
+ return Response(providers, status=status.HTTP_200_OK)
\ No newline at end of file
diff --git a/backend/server/worldtravel/management/commands/download-countries.py b/backend/server/worldtravel/management/commands/download-countries.py
index 06382cb..f5c5702 100644
--- a/backend/server/worldtravel/management/commands/download-countries.py
+++ b/backend/server/worldtravel/management/commands/download-countries.py
@@ -1,9 +1,10 @@
import os
from django.core.management.base import BaseCommand
import requests
-from worldtravel.models import Country, Region
+from worldtravel.models import Country, Region, City
from django.db import transaction
-import json
+from tqdm import tqdm
+import ijson
from django.conf import settings
@@ -37,33 +38,54 @@ def saveCountryFlag(country_code):
class Command(BaseCommand):
help = 'Imports the world travel data'
- def handle(self, *args, **options):
- countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions-{COUNTRY_REGION_JSON_VERSION}.json')
- if not os.path.exists(countries_json_path):
- res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/countries%2Bstates.json')
+ def add_arguments(self, parser):
+ parser.add_argument('--force', action='store_true', help='Force download the countries+regions+states.json file')
+
+ def handle(self, **options):
+ force = options['force']
+ batch_size = 100
+ countries_json_path = os.path.join(settings.MEDIA_ROOT, f'countries+regions+states-{COUNTRY_REGION_JSON_VERSION}.json')
+ if not os.path.exists(countries_json_path) or force:
+ res = requests.get(f'https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/{COUNTRY_REGION_JSON_VERSION}/json/countries%2Bstates%2Bcities.json')
if res.status_code == 200:
with open(countries_json_path, 'w') as f:
f.write(res.text)
+ self.stdout.write(self.style.SUCCESS('countries+regions+states.json downloaded successfully'))
else:
- self.stdout.write(self.style.ERROR('Error downloading countries+regions.json'))
+ self.stdout.write(self.style.ERROR('Error downloading countries+regions+states.json'))
return
+ elif not os.path.isfile(countries_json_path):
+ self.stdout.write(self.style.ERROR('countries+regions+states.json is not a file'))
+ return
+ elif os.path.getsize(countries_json_path) == 0:
+ self.stdout.write(self.style.ERROR('countries+regions+states.json is empty'))
+ elif Country.objects.count() == 0 or Region.objects.count() == 0 or City.objects.count() == 0:
+ self.stdout.write(self.style.WARNING('Some region data is missing. Re-importing all data.'))
+ else:
+ self.stdout.write(self.style.SUCCESS('Latest country, region, and state data already downloaded.'))
+ return
with open(countries_json_path, 'r') as f:
- data = json.load(f)
+ f = open(countries_json_path, 'rb')
+ parser = ijson.items(f, 'item')
with transaction.atomic():
existing_countries = {country.country_code: country for country in Country.objects.all()}
existing_regions = {region.id: region for region in Region.objects.all()}
+ existing_cities = {city.id: city for city in City.objects.all()}
countries_to_create = []
regions_to_create = []
countries_to_update = []
regions_to_update = []
+ cities_to_create = []
+ cities_to_update = []
processed_country_codes = set()
processed_region_ids = set()
+ processed_city_ids = set()
- for country in data:
+ for country in parser:
country_code = country['iso2']
country_name = country['name']
country_subregion = country['subregion']
@@ -93,7 +115,6 @@ class Command(BaseCommand):
countries_to_create.append(country_obj)
saveCountryFlag(country_code)
- self.stdout.write(self.style.SUCCESS(f'Country {country_name} prepared'))
if country['states']:
for state in country['states']:
@@ -102,6 +123,11 @@ class Command(BaseCommand):
latitude = round(float(state['latitude']), 6) if state['latitude'] else None
longitude = round(float(state['longitude']), 6) if state['longitude'] else None
+ # Check for duplicate regions
+ if state_id in processed_region_ids:
+ # self.stdout.write(self.style.ERROR(f'State {state_id} already processed'))
+ continue
+
processed_region_ids.add(state_id)
if state_id in existing_regions:
@@ -120,7 +146,40 @@ class Command(BaseCommand):
latitude=latitude
)
regions_to_create.append(region_obj)
- self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
+ # self.stdout.write(self.style.SUCCESS(f'State {state_id} prepared'))
+
+ if 'cities' in state and len(state['cities']) > 0:
+ for city in state['cities']:
+ city_id = f"{state_id}-{city['id']}"
+ city_name = city['name']
+ latitude = round(float(city['latitude']), 6) if city['latitude'] else None
+ longitude = round(float(city['longitude']), 6) if city['longitude'] else None
+
+ # Check for duplicate cities
+ if city_id in processed_city_ids:
+ # self.stdout.write(self.style.ERROR(f'City {city_id} already processed'))
+ continue
+
+ processed_city_ids.add(city_id)
+
+ if city_id in existing_cities:
+ city_obj = existing_cities[city_id]
+ city_obj.name = city_name
+ city_obj.region = region_obj
+ city_obj.longitude = longitude
+ city_obj.latitude = latitude
+ cities_to_update.append(city_obj)
+ else:
+ city_obj = City(
+ id=city_id,
+ name=city_name,
+ region=region_obj,
+ longitude=longitude,
+ latitude=latitude
+ )
+ cities_to_create.append(city_obj)
+ # self.stdout.write(self.style.SUCCESS(f'City {city_id} prepared'))
+
else:
state_id = f"{country_code}-00"
processed_region_ids.add(state_id)
@@ -136,18 +195,35 @@ class Command(BaseCommand):
country=country_obj
)
regions_to_create.append(region_obj)
- self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
+ # self.stdout.write(self.style.SUCCESS(f'Region {state_id} prepared for {country_name}'))
+ for i in tqdm(range(0, len(countries_to_create), batch_size), desc="Processing countries"):
+ batch = countries_to_create[i:i + batch_size]
+ Country.objects.bulk_create(batch)
- # Bulk create new countries and regions
- Country.objects.bulk_create(countries_to_create)
- Region.objects.bulk_create(regions_to_create)
+ for i in tqdm(range(0, len(regions_to_create), batch_size), desc="Processing regions"):
+ batch = regions_to_create[i:i + batch_size]
+ Region.objects.bulk_create(batch)
- # Bulk update existing countries and regions
- Country.objects.bulk_update(countries_to_update, ['name', 'subregion', 'capital'])
- Region.objects.bulk_update(regions_to_update, ['name', 'country', 'longitude', 'latitude'])
+ for i in tqdm(range(0, len(cities_to_create), batch_size), desc="Processing cities"):
+ batch = cities_to_create[i:i + batch_size]
+ City.objects.bulk_create(batch)
- # Delete countries and regions that are no longer in the data
+ # Process updates in batches
+ for i in range(0, len(countries_to_update), batch_size):
+ batch = countries_to_update[i:i + batch_size]
+ for i in tqdm(range(0, len(countries_to_update), batch_size), desc="Updating countries"):
+ batch = countries_to_update[i:i + batch_size]
+ Country.objects.bulk_update(batch, ['name', 'subregion', 'capital', 'longitude', 'latitude'])
+
+ for i in tqdm(range(0, len(regions_to_update), batch_size), desc="Updating regions"):
+ batch = regions_to_update[i:i + batch_size]
+ Region.objects.bulk_update(batch, ['name', 'country', 'longitude', 'latitude'])
+
+ for i in tqdm(range(0, len(cities_to_update), batch_size), desc="Updating cities"):
+ batch = cities_to_update[i:i + batch_size]
+ City.objects.bulk_update(batch, ['name', 'region', 'longitude', 'latitude'])
Country.objects.exclude(country_code__in=processed_country_codes).delete()
Region.objects.exclude(id__in=processed_region_ids).delete()
+ City.objects.exclude(id__in=processed_city_ids).delete()
self.stdout.write(self.style.SUCCESS('All data imported successfully'))
\ No newline at end of file
diff --git a/backend/server/worldtravel/migrations/0012_city.py b/backend/server/worldtravel/migrations/0012_city.py
new file mode 100644
index 0000000..d14b088
--- /dev/null
+++ b/backend/server/worldtravel/migrations/0012_city.py
@@ -0,0 +1,27 @@
+# Generated by Django 5.0.8 on 2025-01-09 15:11
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('worldtravel', '0011_country_latitude_country_longitude'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='City',
+ fields=[
+ ('id', models.CharField(primary_key=True, serialize=False)),
+ ('name', models.CharField(max_length=100)),
+ ('longitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('latitude', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
+ ('region', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.region')),
+ ],
+ options={
+ 'verbose_name_plural': 'Cities',
+ },
+ ),
+ ]
diff --git a/backend/server/worldtravel/migrations/0013_visitedcity.py b/backend/server/worldtravel/migrations/0013_visitedcity.py
new file mode 100644
index 0000000..3b1e294
--- /dev/null
+++ b/backend/server/worldtravel/migrations/0013_visitedcity.py
@@ -0,0 +1,24 @@
+# Generated by Django 5.0.8 on 2025-01-09 17:00
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('worldtravel', '0012_city'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='VisitedCity',
+ fields=[
+ ('id', models.AutoField(primary_key=True, serialize=False)),
+ ('city', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='worldtravel.city')),
+ ('user_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
+ ],
+ ),
+ ]
diff --git a/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py
new file mode 100644
index 0000000..f2ea944
--- /dev/null
+++ b/backend/server/worldtravel/migrations/0014_alter_visitedcity_options.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.0.8 on 2025-01-09 18:08
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('worldtravel', '0013_visitedcity'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='visitedcity',
+ options={'verbose_name_plural': 'Visited Cities'},
+ ),
+ ]
diff --git a/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py
new file mode 100644
index 0000000..5d7223b
--- /dev/null
+++ b/backend/server/worldtravel/migrations/0015_city_insert_id_country_insert_id_region_insert_id.py
@@ -0,0 +1,28 @@
+# Generated by Django 5.0.8 on 2025-01-13 17:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('worldtravel', '0014_alter_visitedcity_options'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='city',
+ name='insert_id',
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='country',
+ name='insert_id',
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='region',
+ name='insert_id',
+ field=models.UUIDField(blank=True, null=True),
+ ),
+ ]
diff --git a/backend/server/worldtravel/models.py b/backend/server/worldtravel/models.py
index 2acc629..6c7ebb8 100644
--- a/backend/server/worldtravel/models.py
+++ b/backend/server/worldtravel/models.py
@@ -17,6 +17,7 @@ class Country(models.Model):
capital = models.CharField(max_length=100, blank=True, null=True)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ insert_id = models.UUIDField(unique=False, blank=True, null=True)
class Meta:
verbose_name = "Country"
@@ -31,6 +32,21 @@ class Region(models.Model):
country = models.ForeignKey(Country, on_delete=models.CASCADE)
longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ insert_id = models.UUIDField(unique=False, blank=True, null=True)
+
+ def __str__(self):
+ return self.name
+
+class City(models.Model):
+ id = models.CharField(primary_key=True)
+ name = models.CharField(max_length=100)
+ region = models.ForeignKey(Region, on_delete=models.CASCADE)
+ longitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ latitude = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
+ insert_id = models.UUIDField(unique=False, blank=True, null=True)
+
+ class Meta:
+ verbose_name_plural = "Cities"
def __str__(self):
return self.name
@@ -47,4 +63,21 @@ class VisitedRegion(models.Model):
def save(self, *args, **kwargs):
if VisitedRegion.objects.filter(user_id=self.user_id, region=self.region).exists():
raise ValidationError("Region already visited by user.")
- super().save(*args, **kwargs)
\ No newline at end of file
+ super().save(*args, **kwargs)
+
+class VisitedCity(models.Model):
+ id = models.AutoField(primary_key=True)
+ user_id = models.ForeignKey(
+ User, on_delete=models.CASCADE, default=default_user_id)
+ city = models.ForeignKey(City, on_delete=models.CASCADE)
+
+ def __str__(self):
+ return f'{self.city.name} ({self.city.region.name}) visited by: {self.user_id.username}'
+
+ def save(self, *args, **kwargs):
+ if VisitedCity.objects.filter(user_id=self.user_id, city=self.city).exists():
+ raise ValidationError("City already visited by user.")
+ super().save(*args, **kwargs)
+
+ class Meta:
+ verbose_name_plural = "Visited Cities"
\ No newline at end of file
diff --git a/backend/server/worldtravel/serializers.py b/backend/server/worldtravel/serializers.py
index 70f569b..99c7379 100644
--- a/backend/server/worldtravel/serializers.py
+++ b/backend/server/worldtravel/serializers.py
@@ -1,5 +1,5 @@
import os
-from .models import Country, Region, VisitedRegion
+from .models import Country, Region, VisitedRegion, City, VisitedCity
from rest_framework import serializers
from main.utils import CustomModelSerializer
@@ -33,10 +33,20 @@ class CountrySerializer(serializers.ModelSerializer):
class RegionSerializer(serializers.ModelSerializer):
+ num_cities = serializers.SerializerMethodField()
class Meta:
model = Region
fields = '__all__'
- read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude']
+ read_only_fields = ['id', 'name', 'country', 'longitude', 'latitude', 'num_cities']
+
+ def get_num_cities(self, obj):
+ return City.objects.filter(region=obj).count()
+
+class CitySerializer(serializers.ModelSerializer):
+ class Meta:
+ model = City
+ fields = '__all__'
+ read_only_fields = ['id', 'name', 'region', 'longitude', 'latitude']
class VisitedRegionSerializer(CustomModelSerializer):
longitude = serializers.DecimalField(source='region.longitude', max_digits=9, decimal_places=6, read_only=True)
@@ -46,4 +56,14 @@ class VisitedRegionSerializer(CustomModelSerializer):
class Meta:
model = VisitedRegion
fields = ['id', 'user_id', 'region', 'longitude', 'latitude', 'name']
+ read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
+
+class VisitedCitySerializer(CustomModelSerializer):
+ longitude = serializers.DecimalField(source='city.longitude', max_digits=9, decimal_places=6, read_only=True)
+ latitude = serializers.DecimalField(source='city.latitude', max_digits=9, decimal_places=6, read_only=True)
+ name = serializers.CharField(source='city.name', read_only=True)
+
+ class Meta:
+ model = VisitedCity
+ fields = ['id', 'user_id', 'city', 'longitude', 'latitude', 'name']
read_only_fields = ['user_id', 'id', 'longitude', 'latitude', 'name']
\ No newline at end of file
diff --git a/backend/server/worldtravel/urls.py b/backend/server/worldtravel/urls.py
index 46fe197..f28beda 100644
--- a/backend/server/worldtravel/urls.py
+++ b/backend/server/worldtravel/urls.py
@@ -2,15 +2,17 @@
from django.urls import include, path
from rest_framework.routers import DefaultRouter
-from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country
-
+from .views import CountryViewSet, RegionViewSet, VisitedRegionViewSet, regions_by_country, visits_by_country, cities_by_region, VisitedCityViewSet, visits_by_region
router = DefaultRouter()
router.register(r'countries', CountryViewSet, basename='countries')
router.register(r'regions', RegionViewSet, basename='regions')
router.register(r'visitedregion', VisitedRegionViewSet, basename='visitedregion')
+router.register(r'visitedcity', VisitedCityViewSet, basename='visitedcity')
urlpatterns = [
path('', include(router.urls)),
path('/regions/', regions_by_country, name='regions-by-country'),
- path('/visits/', visits_by_country, name='visits-by-country')
+ path('/visits/', visits_by_country, name='visits-by-country'),
+ path('regions//cities/', cities_by_region, name='cities-by-region'),
+ path('regions//cities/visits/', visits_by_region, name='visits-by-region'),
]
diff --git a/backend/server/worldtravel/views.py b/backend/server/worldtravel/views.py
index 5a92e39..c77309d 100644
--- a/backend/server/worldtravel/views.py
+++ b/backend/server/worldtravel/views.py
@@ -1,6 +1,6 @@
from django.shortcuts import render
-from .models import Country, Region, VisitedRegion
-from .serializers import CountrySerializer, RegionSerializer, VisitedRegionSerializer
+from .models import Country, Region, VisitedRegion, City, VisitedCity
+from .serializers import CitySerializer, CountrySerializer, RegionSerializer, VisitedRegionSerializer, VisitedCitySerializer
from rest_framework import viewsets, status
from rest_framework.permissions import IsAuthenticated
from django.shortcuts import get_object_or_404
@@ -33,6 +33,23 @@ def visits_by_country(request, country_code):
serializer = VisitedRegionSerializer(visits, many=True)
return Response(serializer.data)
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def cities_by_region(request, region_id):
+ region = get_object_or_404(Region, id=region_id)
+ cities = City.objects.filter(region=region).order_by('name')
+ serializer = CitySerializer(cities, many=True)
+ return Response(serializer.data)
+
+@api_view(['GET'])
+@permission_classes([IsAuthenticated])
+def visits_by_region(request, region_id):
+ region = get_object_or_404(Region, id=region_id)
+ visits = VisitedCity.objects.filter(city__region=region, user_id=request.user.id)
+
+ serializer = VisitedCitySerializer(visits, many=True)
+ return Response(serializer.data)
+
class CountryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Country.objects.all().order_by('name')
serializer_class = CountrySerializer
@@ -93,4 +110,46 @@ class VisitedRegionViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
\ No newline at end of file
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+ def destroy(self, request, **kwargs):
+ # delete by region id
+ region = get_object_or_404(Region, id=kwargs['pk'])
+ visited_region = VisitedRegion.objects.filter(user_id=request.user.id, region=region)
+ if visited_region.exists():
+ visited_region.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ else:
+ return Response({"error": "Visited region not found."}, status=status.HTTP_404_NOT_FOUND)
+
+class VisitedCityViewSet(viewsets.ModelViewSet):
+ serializer_class = VisitedCitySerializer
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ return VisitedCity.objects.filter(user_id=self.request.user.id)
+
+ def perform_create(self, serializer):
+ serializer.save(user_id=self.request.user)
+
+ def create(self, request, *args, **kwargs):
+ request.data['user_id'] = request.user
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ self.perform_create(serializer)
+ # if the region is not visited, visit it
+ region = serializer.validated_data['city'].region
+ if not VisitedRegion.objects.filter(user_id=request.user.id, region=region).exists():
+ VisitedRegion.objects.create(user_id=request.user, region=region)
+ headers = self.get_success_headers(serializer.data)
+ return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
+
+ def destroy(self, request, **kwargs):
+ # delete by city id
+ city = get_object_or_404(City, id=kwargs['pk'])
+ visited_city = VisitedCity.objects.filter(user_id=request.user.id, city=city)
+ if visited_city.exists():
+ visited_city.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+ else:
+ return Response({"error": "Visited city not found."}, status=status.HTTP_404_NOT_FOUND)
\ No newline at end of file
diff --git a/documentation/.vitepress/config.mts b/documentation/.vitepress/config.mts
index f119b27..5638acf 100644
--- a/documentation/.vitepress/config.mts
+++ b/documentation/.vitepress/config.mts
@@ -92,6 +92,28 @@ export default defineConfig({
text: "Immich Integration",
link: "/docs/configuration/immich_integration",
},
+ {
+ text: "Social Auth and OIDC",
+ link: "/docs/configuration/social_auth",
+ },
+ {
+ text: "Authentication Providers",
+ collapsed: false,
+ items: [
+ {
+ text: "Authentik",
+ link: "/docs/configuration/social_auth/authentik",
+ },
+ {
+ text: "GitHub",
+ link: "/docs/configuration/social_auth/github",
+ },
+ {
+ text: "Open ID Connect",
+ link: "/docs/configuration/social_auth/oidc",
+ },
+ ],
+ },
{
text: "Update App",
link: "/docs/configuration/updating",
diff --git a/documentation/docs/configuration/social_auth.md b/documentation/docs/configuration/social_auth.md
new file mode 100644
index 0000000..9da70e1
--- /dev/null
+++ b/documentation/docs/configuration/social_auth.md
@@ -0,0 +1,15 @@
+# Social Authentication
+
+AdventureLog support autentication via 3rd party services and self-hosted identity providers. Once these services are enabled, users can log in to AdventureLog using their accounts from these services and link exising AdventureLog accounts to these services for easier access.
+
+The steps for each service varies so please refer to the specific service's documentation for more information.
+
+## Supported Services
+
+- [Authentik](social_auth/authentik.md) (self-hosted)
+- [GitHub](social_auth/github.md)
+- [Open ID Connect](social_auth/oidc.md)
+
+## Linking Existing Accounts
+
+If you already have an AdventureLog account and would like to link it to a 3rd party service, you can do so by logging in to AdventureLog and navigating to the `Account Settings` page. From there, scroll down to `Social and OIDC Authentication` and click the `Launch Account Connections` button. If identity providers have been enabled on your instance, you will see a list of available services to link to.
diff --git a/documentation/docs/configuration/social_auth/authentik.md b/documentation/docs/configuration/social_auth/authentik.md
new file mode 100644
index 0000000..6bc62d9
--- /dev/null
+++ b/documentation/docs/configuration/social_auth/authentik.md
@@ -0,0 +1,56 @@
+# Authentik OIDC Authentication
+
+
+
+Authentik is a self-hosted identity provider that supports OpenID Connect and OAuth2. AdventureLog can be configured to use Authentik as an identity provider for social authentication. Learn more about Authentik at [goauthentik.io](https://goauthentik.io/).
+
+Once Authentik is configured by the administrator, users can log in to AdventureLog using their Authentik account and link existing AdventureLog accounts to Authentik for easier access.
+
+# Configuration
+
+To enable Authentik as an identity provider, the administrator must first configure Authentik to allow AdventureLog to authenticate users.
+
+### Authentik Configuration
+
+1. Log in to Authentik and navigate to the `Providers` page and create a new provider.
+2. Select `OAuth2/OpenID Provider` as the provider type.
+3. Name it `AdventureLog` or any other name you prefer.
+4. Set the `Redirect URI` of type `Regex` to `^http:///accounts/oidc/.*$` where `` is the URL of your AdventureLog Server service.
+5. Copy the `Client ID` and `Client Secret` generated by Authentik, you will need these to configure AdventureLog.
+6. Create an application in Authentik and assign the provider to it, name the `slug` `adventurelog` or any other name you prefer.
+7. If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png).
+
+### AdventureLog Configuration
+
+This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server.
+
+1. Login to AdventureLog as an administrator and navigate to the `Settings` page.
+2. Scroll down to the `Administration Settings` and launch the admin panel.
+3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields:
+
+ - Provider: `OpenID Connect`
+ - Provider ID: Autnentik Client ID
+ - Name: `Authentik`
+ - Client ID: Authentik Client ID
+ - Secret Key: Authentik Client Secret
+ - Key: can be left blank
+ - Settings: (make sure http/https is set correctly)
+
+ ```json
+ {
+ "server_url": "http:///application/o/[YOUR_SLUG]/"
+ }
+ ```
+
+ ::: warning
+ `localhost` is most likely not a valid `server_url` for Authentik in this instance because `localhost` is the server running AdventureLog, not Authentik. You should use the IP address of the server running Authentik or the domain name if you have one.
+
+- Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites.
+
+#### What it Should Look Like
+
+
+
+4. Save the configuration.
+
+Ensure that the Authentik server is running and accessible by AdventureLog. Users should now be able to log in to AdventureLog using their Authentik account.
diff --git a/documentation/docs/configuration/social_auth/github.md b/documentation/docs/configuration/social_auth/github.md
new file mode 100644
index 0000000..2239dc7
--- /dev/null
+++ b/documentation/docs/configuration/social_auth/github.md
@@ -0,0 +1,44 @@
+# GitHub Social Authentication
+
+AdventureLog can be configured to use GitHub as an identity provider for social authentication. Users can then log in to AdventureLog using their GitHub account.
+
+# Configuration
+
+To enable GitHub as an identity provider, the administrator must first configure GitHub to allow AdventureLog to authenticate users.
+
+### GitHub Configuration
+
+1. Visit the GitHub OAuth Apps Settings page at [https://github.com/settings/developers](https://github.com/settings/developers).
+2. Click on `New OAuth App`.
+3. Fill in the following fields:
+
+ - Application Name: `AdventureLog` or any other name you prefer.
+ - Homepage URL: `` where `` is the URL of your AdventureLog Frontend service.
+ - Application Description: `AdventureLog` or any other description you prefer.
+ - Authorization callback URL: `http:///accounts/github/login/callback/` where `` is the URL of your AdventureLog Backend service.
+ - If you want the logo, you can find it [here](https://adventurelog.app/adventurelog.png).
+
+### AdventureLog Configuration
+
+This configuration is done in the [Admin Panel](../../guides/admin_panel.md). You can either launch the pannel directly from the `Settings` page or navigate to `/admin` on your AdventureLog server.
+
+1. Login to AdventureLog as an administrator and navigate to the `Settings` page.
+2. Scroll down to the `Administration Settings` and launch the admin panel.
+3. In the admin panel, navigate to the `Social Accounts` section and click the add button next to `Social applications`. Fill in the following fields:
+
+ - Provider: `GitHub`
+ - Provider ID: GitHub Client ID
+ - Name: `GitHub`
+ - Client ID: GitHub Client ID
+ - Secret Key: GitHub Client Secret
+ - Key: can be left blank
+ - Settings: can be left blank
+ - Sites: move over the sites you want to enable Authentik on, usually `example.com` and `www.example.com` unless you renamed your sites.
+
+#### What it Should Look Like
+
+
+
+4. Save the configuration.
+
+Users should now be able to log in to AdventureLog using their GitHub account, and link it to exisiting accounts.
diff --git a/documentation/docs/configuration/social_auth/oidc.md b/documentation/docs/configuration/social_auth/oidc.md
new file mode 100644
index 0000000..0b0384d
--- /dev/null
+++ b/documentation/docs/configuration/social_auth/oidc.md
@@ -0,0 +1,7 @@
+# OIDC Social Authentication
+
+AdventureLog can be configured to use OpenID Connect (OIDC) as an identity provider for social authentication. Users can then log in to AdventureLog using their OIDC account.
+
+The configuration is basically the same as [Authentik](./authentik.md), but you replace the client and secret with the OIDC client and secret provided by your OIDC provider. The `server_url` should be the URL of your OIDC provider where you can find the OIDC configuration.
+
+Each provider has a different configuration, so you will need to check the documentation of your OIDC provider to find the correct configuration.
diff --git a/documentation/docs/configuration/updating.md b/documentation/docs/configuration/updating.md
index c4eb3d3..aff9dd7 100644
--- a/documentation/docs/configuration/updating.md
+++ b/documentation/docs/configuration/updating.md
@@ -20,5 +20,5 @@ docker exec -it bash
Once you are in the container run the following command to resync the region data.
```bash
-python manage.py download-countries
+python manage.py download-countries --force
```
diff --git a/documentation/docs/install/docker.md b/documentation/docs/install/docker.md
index b46201d..c53d980 100644
--- a/documentation/docs/install/docker.md
+++ b/documentation/docs/install/docker.md
@@ -11,6 +11,12 @@ Docker is the preferred way to run AdventureLog on your local machine. It is a l
Get the `docker-compose.yml` file from the AdventureLog repository. You can download it from [here](https://github.com/seanmorley15/AdventureLog/blob/main/docker-compose.yml) or run this command to download it directly to your machine:
+::: tip
+
+If running on an ARM based machine, you will need to use a different PostGIS Image. It is recommended to use the `tobi312/rpi-postgresql-postgis:15-3.3-alpine-arm` image or a custom version found [here](https://hub.docker.com/r/tobi312/rpi-postgresql-postgis/tags). The AdventureLog containers are ARM compatible.
+
+:::
+
```bash
wget https://raw.githubusercontent.com/seanmorley15/AdventureLog/main/docker-compose.yml
```
diff --git a/documentation/public/authentik_settings.png b/documentation/public/authentik_settings.png
new file mode 100644
index 0000000..d48e2c3
Binary files /dev/null and b/documentation/public/authentik_settings.png differ
diff --git a/documentation/public/github_settings.png b/documentation/public/github_settings.png
new file mode 100644
index 0000000..5d54369
Binary files /dev/null and b/documentation/public/github_settings.png differ
diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts
index 166ff33..56c5f94 100644
--- a/frontend/src/app.d.ts
+++ b/frontend/src/app.d.ts
@@ -15,6 +15,7 @@ declare global {
profile_pic: string | null;
uuid: string;
public_profile: boolean;
+ has_password: boolean;
} | null;
locale: string;
}
diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts
index 91e1b60..12cd017 100644
--- a/frontend/src/hooks.server.ts
+++ b/frontend/src/hooks.server.ts
@@ -3,6 +3,7 @@ import { sequence } from '@sveltejs/kit/hooks';
const PUBLIC_SERVER_URL = process.env['PUBLIC_SERVER_URL'];
export const authHook: Handle = async ({ event, resolve }) => {
+ event.cookies.delete('csrftoken', { path: '/' });
try {
let sessionid = event.cookies.get('sessionid');
diff --git a/frontend/src/lib/components/AdventureModal.svelte b/frontend/src/lib/components/AdventureModal.svelte
index eda9df6..322e67f 100644
--- a/frontend/src/lib/components/AdventureModal.svelte
+++ b/frontend/src/lib/components/AdventureModal.svelte
@@ -228,6 +228,37 @@
}
}
+ async function handleMultipleFiles(event: Event) {
+ const files = (event.target as HTMLInputElement).files;
+ if (files) {
+ for (const file of files) {
+ await uploadImage(file);
+ }
+ }
+ }
+
+ async function uploadImage(file: File) {
+ let formData = new FormData();
+ formData.append('image', file);
+ formData.append('adventure', adventure.id);
+
+ let res = await fetch(`/adventures?/image`, {
+ method: 'POST',
+ body: formData
+ });
+ if (res.ok) {
+ let newData = deserialize(await res.text()) as { data: { id: string; image: string } };
+ console.log(newData);
+ let newImage = { id: newData.data.id, image: newData.data.image, is_primary: false };
+ console.log(newImage);
+ images = [...images, newImage];
+ adventure.images = images;
+ addToast('success', $t('adventures.image_upload_success'));
+ } else {
+ addToast('error', $t('adventures.image_upload_error'));
+ }
+ }
+
async function fetchImage() {
try {
let res = await fetch(url);
@@ -241,39 +272,10 @@
formData.append('image', file);
formData.append('adventure', adventure.id);
- let res2 = await fetch(`/adventures?/image`, {
- method: 'POST',
- body: formData
- });
- let data2 = await res2.json();
-
- if (data2.type === 'success') {
- console.log('Response Data:', data2);
-
- // Deserialize the nested data
- let rawData = JSON.parse(data2.data); // Parse the data field
- console.log('Deserialized Data:', rawData);
-
- // Assuming the first object in the array is the new image
- let newImage = {
- id: rawData[1],
- image: rawData[2], // This is the URL for the image
- is_primary: false
- };
- console.log('New Image:', newImage);
-
- // Update images and adventure
- images = [...images, newImage];
- adventure.images = images;
-
- addToast('success', $t('adventures.image_upload_success'));
- url = '';
- } else {
- addToast('error', $t('adventures.image_upload_error'));
- }
- } catch (error) {
- console.error('Error in fetchImage:', error);
- addToast('error', $t('adventures.image_upload_error'));
+ await uploadImage(file);
+ url = '';
+ } catch (e) {
+ imageError = $t('adventures.image_fetch_failed');
}
}
@@ -365,15 +367,31 @@
async function markVisited() {
console.log(reverseGeocodePlace);
if (reverseGeocodePlace) {
- let res = await fetch(`/worldtravel?/markVisited`, {
- method: 'POST',
- body: JSON.stringify({ regionId: reverseGeocodePlace.id })
- });
- if (res.ok) {
- reverseGeocodePlace.is_visited = true;
- addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
- } else {
- addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`);
+ if (!reverseGeocodePlace.region_visited && reverseGeocodePlace.region_id) {
+ let region_res = await fetch(`/api/visitedregion`, {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ body: JSON.stringify({ region: reverseGeocodePlace.region_id })
+ });
+ if (region_res.ok) {
+ reverseGeocodePlace.region_visited = true;
+ addToast('success', `Visit to ${reverseGeocodePlace.region} marked`);
+ } else {
+ addToast('error', `Failed to mark visit to ${reverseGeocodePlace.region}`);
+ }
+ }
+ if (!reverseGeocodePlace.city_visited && reverseGeocodePlace.city_id != null) {
+ let city_res = await fetch(`/api/visitedcity`, {
+ headers: { 'Content-Type': 'application/json' },
+ method: 'POST',
+ body: JSON.stringify({ city: reverseGeocodePlace.city_id })
+ });
+ if (city_res.ok) {
+ reverseGeocodePlace.city_visited = true;
+ addToast('success', `Visit to ${reverseGeocodePlace.city} marked`);
+ } else {
+ addToast('error', `Failed to mark visit to ${reverseGeocodePlace.city}`);
+ }
}
}
}
@@ -542,7 +560,10 @@
addToast('error', $t('adventures.adventure_update_error'));
}
}
- if (adventure.is_visited && !reverseGeocodePlace?.is_visited) {
+ if (
+ adventure.is_visited &&
+ (!reverseGeocodePlace?.region_visited || !reverseGeocodePlace?.city_visited)
+ ) {
markVisited();
}
imageSearch = adventure.name;
@@ -785,19 +806,33 @@ it would also work to just use on:click on the MapLibre component itself. -->
{#if reverseGeocodePlace}
{reverseGeocodePlace.region},
+ >{reverseGeocodePlace.city
+ ? reverseGeocodePlace.city + ', '
+ : ''}{reverseGeocodePlace.region},
{reverseGeocodePlace.country}
{$t('adventures.will_be_marked')}
@@ -1022,11 +1059,13 @@ it would also work to just use on:click on the MapLibre component itself. -->
bind:this={fileInput}
accept="image/*"
id="image"
+ multiple
+ on:change={handleMultipleFiles}
/>
-