1
0
Fork 0
mirror of https://github.com/seanmorley15/AdventureLog.git synced 2025-08-02 11:45:17 +02:00

OIDC Auth and City Visits Data

This commit is contained in:
Sean Morley 2025-01-13 17:21:32 -05:00 committed by GitHub
commit ec3ada986d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1776 additions and 484 deletions

View file

@ -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

View file

@ -38,4 +38,4 @@ http {
alias /code/media/; # Serve media files directly
}
}
}
}

View file

@ -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'

View file

@ -20,4 +20,23 @@ class PrintCookiesMiddleware:
def __call__(self, request):
print(request.COOKIES)
response = self.get_response(request)
return response
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

View file

@ -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'])

View file

@ -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'
COUNTRY_REGION_JSON_VERSION = 'v2.5'

View file

@ -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')),

View file

@ -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')})

View file

@ -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
icalendar==6.1.0
ijson==3.3.0
tqdm==4.67.1

View file

@ -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

View file

@ -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)
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)

View file

@ -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'))

View file

@ -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',
},
),
]

View file

@ -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)),
],
),
]

View file

@ -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'},
),
]

View file

@ -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),
),
]

View file

@ -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)
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"

View file

@ -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']

View file

@ -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('<str:country_code>/regions/', regions_by_country, name='regions-by-country'),
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country')
path('<str:country_code>/visits/', visits_by_country, name='visits-by-country'),
path('regions/<str:region_id>/cities/', cities_by_region, name='cities-by-region'),
path('regions/<str:region_id>/cities/visits/', visits_by_region, name='visits-by-region'),
]

View file

@ -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)
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)