diff --git a/comrade-client/google.html b/comrade-client/google.html new file mode 100644 index 0000000..86b9898 --- /dev/null +++ b/comrade-client/google.html @@ -0,0 +1,53 @@ + + + + Google Login + + + + + +
+
+
+
+ + + + diff --git a/comrade/clear_friends.py b/comrade/clear_friends.py new file mode 100644 index 0000000..00a17e3 --- /dev/null +++ b/comrade/clear_friends.py @@ -0,0 +1,20 @@ +import os +import django + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'comrade.settings') +django.setup() + +from comrade_core.models import User + +def clear_friends(): + # Clear all friend relationships + for user in User.objects.all(): + print(f"Clearing friends for {user.username}") + user.friends.clear() + user.friend_requests_sent.clear() + user.friend_requests_received.clear() + + print("Successfully cleared all friend relationships") + +if __name__ == "__main__": + clear_friends() \ No newline at end of file diff --git a/comrade/comrade/settings.py b/comrade/comrade/settings.py index a0458a3..3096433 100644 --- a/comrade/comrade/settings.py +++ b/comrade/comrade/settings.py @@ -12,6 +12,7 @@ import os from pathlib import Path +import socket # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,9 +27,14 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True -ALLOWED_HOSTS = ["knowing-massive-macaw.ngrok-free.app", "localhost", "127.0.0.1"] +ALLOWED_HOSTS = [ + 'localhost', + '127.0.0.1', + '934c-77-240-102-76.ngrok-free.app', +] SITE_ID = 1 +SITE_URL = 'https://934c-77-240-102-76.ngrok-free.app' # Application definition @@ -69,6 +75,7 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', ], } @@ -82,7 +89,32 @@ }, } -CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOWED_ORIGINS = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + "https://accounts.google.com", + "https://934c-77-240-102-76.ngrok-free.app" +] +CORS_ALLOW_METHODS = [ + 'DELETE', + 'GET', + 'OPTIONS', + 'PATCH', + 'POST', + 'PUT', +] +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] ROOT_URLCONF = "comrade.urls" @@ -115,10 +147,28 @@ HEADLESS_TOKEN_STRATEGY = "comrade.token_strategy.ComradeTokenStrategy" -CSRF_TRUSTED_ORIGINS = ["https://knowing-massive-macaw.ngrok-free.app"] +# Get local IP address for OAuth +def get_local_ip(): + try: + # Create a socket to get the local IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + return local_ip + except: + return "127.0.0.1" + +LOCAL_IP = get_local_ip() + +CSRF_TRUSTED_ORIGINS = [ + 'http://localhost:8000', + 'http://127.0.0.1:8000', + 'https://934c-77-240-102-76.ngrok-free.app', +] -LOGIN_URL = "/accounts/login/" -ACCOUNT_DEFAULT_HTTP_PROTOCOL = "http" +LOGIN_URL = '/login/' +ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'https' WSGI_APPLICATION = "comrade.wsgi.application" @@ -177,4 +227,45 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -AUTH_USER_MODEL = "comrade_core.User" \ No newline at end of file +AUTH_USER_MODEL = "comrade_core.User" + +# Social Account Settings +SOCIALACCOUNT_PROVIDERS = { + 'google': { + 'APP': { + 'client_id': '838713168413-ntjqd2o22eihod384btlheuc07g5glai.apps.googleusercontent.com', + 'secret': '', + 'key': '' + }, + 'SCOPE': [ + 'profile', + 'email', + ], + 'AUTH_PARAMS': { + 'access_type': 'online', + 'prompt': 'select_account' + } + } +} + +# AllAuth Settings +ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_UNIQUE_EMAIL = True +ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_AUTHENTICATION_METHOD = 'email' +ACCOUNT_EMAIL_VERIFICATION = 'none' +SOCIALACCOUNT_EMAIL_VERIFICATION = 'none' +SOCIALACCOUNT_QUERY_EMAIL = True +ACCOUNT_DEFAULT_HTTP_PROTOCOL = 'https' +ACCOUNT_LOGOUT_ON_GET = True + +# Login/Logout URLs +LOGIN_REDIRECT_URL = '/map/' +LOGOUT_REDIRECT_URL = '/login/' + +# CSRF Settings +CSRF_COOKIE_SECURE = True +CSRF_COOKIE_HTTPONLY = False +CSRF_USE_SESSIONS = False +CSRF_COOKIE_SAMESITE = 'None' +CSRF_COOKIE_DOMAIN = None \ No newline at end of file diff --git a/comrade/comrade/urls.py b/comrade/comrade/urls.py index 7219cfe..0376191 100644 --- a/comrade/comrade/urls.py +++ b/comrade/comrade/urls.py @@ -16,13 +16,14 @@ """ from django.contrib import admin -from django.urls import include, path -from rest_framework import routers +from django.urls import path, include +from comrade_core import views urlpatterns = [ - path("", include("comrade_core.urls")), - path("admin/", admin.site.urls), - path("api-auth/", include("rest_framework.urls")), - path("accounts/", include("allauth.urls")), - path("_allauth/", include("allauth.headless.urls")), + path('admin/', admin.site.urls), + path('', include('comrade_core.urls')), # Include all comrade_core URLs + path('accounts/', include('allauth.urls')), # allauth URLs for social auth + path('login/', views.login_page, name='login_page'), # Keep our custom login page + path('map/', views.map, name='map'), + path('api/user/info/', views.get_user_info, name='get_user_info'), ] diff --git a/comrade/comrade_core/admin.py b/comrade/comrade_core/admin.py index b36004a..9fb0409 100644 --- a/comrade/comrade_core/admin.py +++ b/comrade/comrade_core/admin.py @@ -2,7 +2,7 @@ from django.contrib.auth.admin import UserAdmin from django.contrib.auth.forms import UserChangeForm -from .models import Skill, Task, User +from .models import Skill, Task, User, LocationConfig, Rating, Review class UserChangeForm(UserChangeForm): @@ -12,30 +12,88 @@ class Meta(UserChangeForm.Meta): class ComradeUserAdmin(UserAdmin): form = UserChangeForm - fieldsets = UserAdmin.fieldsets + ((None, {"fields": ("skills",)}),) + list_display = ['username', 'email', 'location_sharing_level'] + list_filter = ['location_sharing_level', 'is_staff', 'is_active'] + search_fields = ['username', 'email', 'first_name', 'last_name'] + + # Add custom fields to the default UserAdmin fieldsets + fieldsets = UserAdmin.fieldsets + ( + ('Location', { + 'fields': ( + 'latitude', 'longitude', 'location_sharing_level', + 'location_share_with' + ) + }), + ('Skills & Friends', { + 'fields': ( + 'skills', 'friends', 'friend_requests_sent' + ) + }), + ) + filter_horizontal = ('skills', 'friends', 'friend_requests_sent', 'location_share_with') class TaskAdmin(admin.ModelAdmin): list_display = [ - "name", - "owner", - "state", - "lat", - "lon", - "respawn", - "respawn_time", - "base_value", - "criticality", - "contribution", + 'name', 'state', 'owner', 'assignee', 'lat', 'lon', + 'respawn', 'respawn_time', 'base_value', 'criticality', + 'contribution', 'minutes' ] - list_filter = ["state", "respawn", "criticality"] - search_fields = ["name", "description"] + list_filter = ['state', 'respawn', 'criticality', 'owner', 'assignee'] + search_fields = ['name', 'description'] + filter_horizontal = ('skill_read', 'skill_write', 'skill_execute') + fieldsets = ( + ('Basic Info', { + 'fields': ('name', 'description') + }), + ('Permissions', { + 'fields': ('skill_read', 'skill_write', 'skill_execute') + }), + ('Task State', { + 'fields': ('state', 'owner', 'assignee') + }), + ('Location', { + 'fields': ('lat', 'lon') + }), + ('Respawn Settings', { + 'fields': ('respawn', 'respawn_time') + }), + ('Values', { + 'fields': ('base_value', 'criticality', 'contribution', 'minutes') + }), + ('Time Tracking', { + 'fields': ('datetime_start', 'datetime_finish') + }) + ) -class SkillInline(admin.StackedInline): - model = Skill +class SkillAdmin(admin.ModelAdmin): + list_display = ['name'] + search_fields = ['name'] + + +class LocationConfigAdmin(admin.ModelAdmin): + list_display = ['max_distance_km', 'last_updated'] + readonly_fields = ['last_updated'] + + +class RatingAdmin(admin.ModelAdmin): + list_display = ['task', 'happiness', 'time'] + list_filter = ['task'] + search_fields = ['task__name'] + fields = ['task', 'happiness', 'time'] + + +class ReviewAdmin(admin.ModelAdmin): + list_display = ['task', 'done'] + list_filter = ['task'] + search_fields = ['task__name'] + fields = ['task', 'done'] admin.site.register(User, ComradeUserAdmin) admin.site.register(Task, TaskAdmin) -admin.site.register(Skill) +admin.site.register(Skill, SkillAdmin) +admin.site.register(LocationConfig, LocationConfigAdmin) +admin.site.register(Rating, RatingAdmin) +admin.site.register(Review, ReviewAdmin) diff --git a/comrade/comrade_core/consumers.py b/comrade/comrade_core/consumers.py index 50d8d6a..0c07542 100644 --- a/comrade/comrade_core/consumers.py +++ b/comrade/comrade_core/consumers.py @@ -5,10 +5,10 @@ from channels.db import database_sync_to_async from django.contrib.auth.models import AnonymousUser from rest_framework.authtoken.models import Token +from .models import User class LocationConsumer(AsyncWebsocketConsumer): async def connect(self): - self.group_name = 'location_updates' query_string = self.scope['query_string'].decode() query_params = parse_qs(query_string) self.token = query_params.get('token', [None])[0] @@ -16,60 +16,226 @@ async def connect(self): token = await database_sync_to_async(Token.objects.get)(key=self.token) self.user = await sync_to_async(lambda: token.user)() if self.user.is_authenticated: -# self.group_name = f"user_{self.user.id}" + # Create a unique group for this user's location updates + self.location_group = f"location_{self.user.id}" await self.channel_layer.group_add( - self.group_name, + self.location_group, self.channel_name ) - await self.accept() # Accept the WebSocket connection + await self.accept() else: - await self.close() # Close the connection if not authenticated + await self.close() except Token.DoesNotExist: await self.close() async def disconnect(self, close_code): - if self.group_name: + if hasattr(self, 'location_group'): + # Get user's friends and sharing level before sending offline notification + friends = await database_sync_to_async(lambda: list(self.user.get_friends()))() + + # Prepare offline notification + offline_message = { + 'type': 'user_offline', + 'userId': self.user.id + } + + # Send offline notification to all friends + for friend in friends: + friend_location_group = f"location_{friend.id}" + await self.channel_layer.group_send( + friend_location_group, + offline_message + ) + + # If sharing was set to ALL, also notify all non-friends + if self.user.location_sharing_level == User.SharingLevel.ALL: + all_users = await database_sync_to_async( + lambda: list(User.objects.exclude(id=self.user.id).exclude(id__in=[f.id for f in friends])) + )() + + for user in all_users: + user_location_group = f"location_{user.id}" + await self.channel_layer.group_send( + user_location_group, + offline_message + ) + + # Finally, leave the group await self.channel_layer.group_discard( - self.group_name, + self.location_group, self.channel_name ) async def receive(self, text_data): data = json.loads(text_data) - latitude = data['latitude'] - longitude = data['longitude'] - - await self.channel_layer.group_send( - self.group_name, - { - 'type': 'location_update', - 'latitude': latitude, - 'longitude': longitude - } - ) - timestamp = self.user.timestamp + # Handle preference updates + if 'preferences' in data: + await self.update_preferences(data['preferences']) + return + + # Handle heartbeat + if data.get('type') == 'heartbeat': + await self.send(text_data=json.dumps({ + 'type': 'heartbeat_response' + })) + return + + # Handle location updates + if data.get('type') == 'location_update': + latitude = data['latitude'] + longitude = data['longitude'] + accuracy = data.get('accuracy', 50) # Default accuracy if not provided - # Check if enough time has passed since the last save - if timestamp is None or timezone.now() - timestamp > timedelta(seconds=30): - # Save the user's location to the database + # Get user's friends and skills for updates + friends = await database_sync_to_async(lambda: list(self.user.get_friends()))() + skills = await database_sync_to_async( + lambda: list(self.user.skills.values_list('name', flat=True)) + )() + + # Save location regardless of sharing preferences await self.save_user_location(self.user, latitude, longitude) print(f"[{timezone.now()}] Location saved for {self.user.username} at {latitude}, {longitude}") + # Only proceed with sharing if not set to NONE + if self.user.location_sharing_level != User.SharingLevel.NONE: + # First, always send detailed updates to friends + friend_update = { + 'type': 'friend_location', + 'userId': self.user.id, + 'name': f"{self.user.first_name} {self.user.last_name}".strip() or self.user.username, + 'latitude': latitude, + 'longitude': longitude, + 'accuracy': accuracy, + 'timestamp': timezone.now().isoformat(), + 'friends': [{'id': f.id, 'name': f"{f.first_name} {f.last_name}".strip() or f.username} for f in friends], + 'skills': skills + } + + # Send detailed update to all friends - assume they're all active + friend_count = len(friends) + for friend in friends: + friend_location_group = f"location_{friend.id}" + await self.channel_layer.group_send( + friend_location_group, + friend_update + ) + + print(f"[{timezone.now()}] Broadcasting location to {friend_count} friends for {self.user.username}") + + # If sharing level is ALL, send basic info to all non-friend users + if self.user.location_sharing_level == User.SharingLevel.ALL: + # For public users, send location without detailed info + public_update = { + 'type': 'public_location', + 'userId': self.user.id, + 'name': f"{self.user.first_name} {self.user.last_name}".strip() or self.user.username, + 'latitude': latitude, + 'longitude': longitude, + 'accuracy': accuracy, + 'timestamp': timezone.now().isoformat() + } + + # Get ALL users except self and friends + all_users = await database_sync_to_async( + lambda: list(User.objects.exclude(id=self.user.id).exclude(id__in=[f.id for f in friends])) + )() + + # Send to all non-friend users - assume they're all active + public_count = len(all_users) + for user in all_users: + user_location_group = f"location_{user.id}" + await self.channel_layer.group_send( + user_location_group, + public_update + ) + + print(f"[{timezone.now()}] Broadcasting location to {public_count} public users for {self.user.username}") + + async def update_preferences(self, preferences_data): + """Update user's location sharing preferences""" + sharing_level = preferences_data.get('sharing_level') + if sharing_level in dict(User.SharingLevel.choices): + self.user.location_sharing_level = sharing_level + await database_sync_to_async(self.user.save)() + + # Send confirmation back to user + await self.send(text_data=json.dumps({ + 'type': 'preferences_updated', + 'status': 'success', + 'preferences': { + 'sharing_level': self.user.location_sharing_level + } + })) + + async def friend_location(self, event): + """Handler for friend location updates""" + await self.send(text_data=json.dumps({ + 'type': 'friend_location', + 'userId': event['userId'], + 'name': event['name'], + 'latitude': event['latitude'], + 'longitude': event['longitude'], + 'accuracy': event['accuracy'], + 'timestamp': event['timestamp'], + 'friends': event['friends'], + 'skills': event['skills'] + })) + + async def public_location(self, event): + """Handler for public location updates""" + await self.send(text_data=json.dumps({ + 'type': 'public_location', + 'userId': event['userId'], + 'name': event['name'], + 'latitude': event['latitude'], + 'longitude': event['longitude'], + 'accuracy': event['accuracy'], + 'timestamp': event['timestamp'] + })) + + async def friend_details(self, event): + """Handler for friend details updates""" + await self.send(text_data=json.dumps({ + 'type': 'friend_details', + 'userId': event['userId'], + 'name': event['name'], + 'friends': event['friends'], + 'skills': event['skills'] + })) + + async def user_offline(self, event): + """Handler for user offline notifications""" + await self.send(text_data=json.dumps({ + 'type': 'user_offline', + 'userId': event['userId'] + })) async def location_update(self, event): + # Send location update with user identification await self.send(text_data=json.dumps({ + 'type': event['type'], + 'userId': event['userId'], + 'name': event['name'], 'latitude': event['latitude'], - 'longitude': event['longitude'] + 'longitude': event['longitude'], + 'timestamp': event['timestamp'] })) async def save_user_location(self, user, latitude, longitude): - # Create a new UserLocation instance and save it to the database user.latitude = latitude user.longitude = longitude user.timestamp = timezone.now() await database_sync_to_async(user.save)() + async def group_exists(self, group_name): + """Check if a channel group has any members""" + try: + group_channels = await self.channel_layer.group_channels(group_name) + return len(group_channels) > 0 + except (AttributeError, KeyError): + return False + @database_sync_to_async def get_user_from_token(self): token = self.scope['url_route']['kwargs'].get('token') diff --git a/comrade/comrade_core/management/commands/clear_friends.py b/comrade/comrade_core/management/commands/clear_friends.py new file mode 100644 index 0000000..974fc79 --- /dev/null +++ b/comrade/comrade_core/management/commands/clear_friends.py @@ -0,0 +1,14 @@ +from django.core.management.base import BaseCommand +from comrade_core.models import User + +class Command(BaseCommand): + help = 'Clears all friend relationships' + + def handle(self, *args, **options): + # Clear all friend relationships + for user in User.objects.all(): + user.friends.clear() + user.friend_requests_sent.clear() + user.friend_requests_received.clear() + + self.stdout.write(self.style.SUCCESS('Successfully cleared all friend relationships')) \ No newline at end of file diff --git a/comrade/comrade_core/migrations/0003_user_friend_requests_sent_user_friends_and_more.py b/comrade/comrade_core/migrations/0003_user_friend_requests_sent_user_friends_and_more.py new file mode 100644 index 0000000..19e5e1c --- /dev/null +++ b/comrade/comrade_core/migrations/0003_user_friend_requests_sent_user_friends_and_more.py @@ -0,0 +1,53 @@ +# Generated by Django 5.0.9 on 2025-05-19 21:52 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("comrade_core", "0002_alter_user_latitude_alter_user_longitude"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="friend_requests_sent", + field=models.ManyToManyField( + blank=True, + related_name="friend_requests_received", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="user", + name="friends", + field=models.ManyToManyField(blank=True, to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name="user", + name="location_preferences_updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="user", + name="location_share_with", + field=models.ManyToManyField( + blank=True, related_name="shared_locations", to=settings.AUTH_USER_MODEL + ), + ), + migrations.AddField( + model_name="user", + name="location_sharing_level", + field=models.CharField( + choices=[ + ("none", "No sharing"), + ("friends", "Share with friends"), + ("all", "Share with everyone"), + ], + default="all", + max_length=10, + ), + ), + ] diff --git a/comrade/comrade_core/migrations/0004_locationconfig.py b/comrade/comrade_core/migrations/0004_locationconfig.py new file mode 100644 index 0000000..d5d24db --- /dev/null +++ b/comrade/comrade_core/migrations/0004_locationconfig.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.9 on 2025-05-19 22:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("comrade_core", "0003_user_friend_requests_sent_user_friends_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="LocationConfig", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "max_distance_km", + models.FloatField( + default=1.0, + help_text="Maximum distance in kilometers for location sharing", + ), + ), + ("last_updated", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Location Configuration", + "verbose_name_plural": "Location Configuration", + }, + ), + ] diff --git a/comrade/comrade_core/models.py b/comrade/comrade_core/models.py index 3a09b0b..a5c45b4 100644 --- a/comrade/comrade_core/models.py +++ b/comrade/comrade_core/models.py @@ -1,4 +1,5 @@ import datetime +import math from django.contrib.auth.models import AbstractUser from django.core.exceptions import ValidationError @@ -7,7 +8,34 @@ from django.utils.timezone import now +class LocationConfig(models.Model): + """Global configuration for location sharing""" + max_distance_km = models.FloatField( + default=1.0, # Default 1km radius + help_text="Maximum distance in kilometers for location sharing" + ) + last_updated = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = "Location Configuration" + verbose_name_plural = "Location Configuration" + + @classmethod + def get_config(cls): + """Get or create the global configuration""" + config, created = cls.objects.get_or_create(pk=1) + return config + + def __str__(self): + return f"Location sharing config (max distance: {self.max_distance_km}km)" + + class User(AbstractUser): + class SharingLevel(models.TextChoices): + NONE = 'none', 'No sharing' + FRIENDS = 'friends', 'Share with friends' + ALL = 'all', 'Share with everyone' + def __str__(self) -> str: return self.username @@ -18,9 +46,147 @@ def __str__(self) -> str: timestamp = models.DateTimeField(auto_now_add=True) + # Location sharing preferences + location_sharing_level = models.CharField( + max_length=10, + choices=SharingLevel.choices, + default=SharingLevel.ALL + ) + location_share_with = models.ManyToManyField( + 'self', + related_name='shared_locations', + blank=True, + symmetrical=False + ) + location_preferences_updated = models.DateTimeField(auto_now=True) + + # Friends management + friends = models.ManyToManyField( + 'self', + blank=True, + symmetrical=True + ) + friend_requests_sent = models.ManyToManyField( + 'self', + related_name='friend_requests_received', + blank=True, + symmetrical=False + ) + def has_skill(self, skill_name): return self.skills.filter(name=skill_name).exists() + def get_location_sharing_preferences(self): + """Get current location sharing preferences""" + return { + 'sharing_level': self.location_sharing_level, + 'share_with_users': list(self.location_share_with.values_list('id', flat=True)) + } + + def update_location_sharing_preferences(self, sharing_level=None, share_with_users=None): + """Update location sharing preferences""" + if sharing_level in dict(self.SharingLevel.choices): + self.location_sharing_level = sharing_level + + if share_with_users is not None: + self.location_share_with.set(share_with_users) + + self.save() + + # Friend management methods + def send_friend_request(self, user): + """Send a friend request to another user""" + if user == self: + raise ValidationError("Cannot send friend request to yourself") + if user in self.friends.all(): + raise ValidationError("Already friends with this user") + if user in self.friend_requests_sent.all(): + raise ValidationError("Friend request already sent") + if self in user.friend_requests_sent.all(): + raise ValidationError("This user has already sent you a friend request") + + self.friend_requests_sent.add(user) + return True + + def accept_friend_request(self, user): + """Accept a friend request from another user""" + if user not in self.friend_requests_received.all(): + raise ValidationError("No friend request from this user") + + self.friends.add(user) + self.friend_requests_received.remove(user) + return True + + def reject_friend_request(self, user): + """Reject a friend request from another user""" + if user not in self.friend_requests_received.all(): + raise ValidationError("No friend request from this user") + + self.friend_requests_received.remove(user) + return True + + def remove_friend(self, user): + """Remove a friend""" + if user not in self.friends.all(): + raise ValidationError("Not friends with this user") + + self.friends.remove(user) + return True + + def get_friends(self): + """Get list of friends""" + return self.friends.all() + + def get_pending_friend_requests(self): + """Get list of pending friend requests""" + return self.friend_requests_received.all() + + def get_sent_friend_requests(self): + """Get list of sent friend requests""" + return self.friend_requests_sent.all() + + def is_friend_with(self, user): + """Check if user is friends with another user""" + return user in self.friends.all() + + def has_pending_request_from(self, user): + """Check if user has a pending friend request from another user""" + return user in self.friend_requests_received.all() + + def has_sent_request_to(self, user): + """Check if user has sent a friend request to another user""" + return user in self.friend_requests_sent.all() + + def distance_to(self, other_user): + """Calculate distance to another user in kilometers using Haversine formula""" + from math import radians, sin, cos, sqrt, atan2 + + # Convert latitude and longitude to radians + lat1, lon1 = radians(self.latitude), radians(self.longitude) + lat2, lon2 = radians(other_user.latitude), radians(other_user.longitude) + + # Haversine formula + dlon = lon2 - lon1 + dlat = lat2 - lat1 + a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2 + c = 2 * atan2(sqrt(a), sqrt(1-a)) + distance = 6371 * c # Earth's radius in km * c + + return distance + + def get_nearby_users(self): + """Get users within the configured distance""" + config = LocationConfig.get_config() + nearby_users = [] + + for user in User.objects.exclude(id=self.id): + if user.location_sharing_level == User.SharingLevel.ALL: + distance = self.distance_to(user) + if distance <= config.max_distance_km: + nearby_users.append(user) + + return nearby_users + class Skill(models.Model): name = models.CharField(max_length=32) @@ -164,6 +330,14 @@ def review(self, user: User): self.state = Task.State.DONE self.save() + def debug_reset(self): + """Debug method to reset task to OPEN state""" + self.state = Task.State.OPEN + self.assignee = None + self.datetime_start = None + self.datetime_finish = None + self.save() + class Rating(models.Model): task = models.ForeignKey( diff --git a/comrade/comrade_core/serializers.py b/comrade/comrade_core/serializers.py index 67bce52..bc788ce 100644 --- a/comrade/comrade_core/serializers.py +++ b/comrade/comrade_core/serializers.py @@ -32,6 +32,25 @@ class Meta: class TaskSerializer(serializers.ModelSerializer): + skill_execute_names = serializers.SerializerMethodField() + skill_read_names = serializers.SerializerMethodField() + skill_write_names = serializers.SerializerMethodField() + assignee_name = serializers.SerializerMethodField() + + def get_skill_execute_names(self, obj): + return [skill.name for skill in obj.skill_execute.all()] + + def get_skill_read_names(self, obj): + return [skill.name for skill in obj.skill_read.all()] + + def get_skill_write_names(self, obj): + return [skill.name for skill in obj.skill_write.all()] + + def get_assignee_name(self, obj): + if obj.assignee: + return f"{obj.assignee.first_name} {obj.assignee.last_name}".strip() or obj.assignee.username + return None + class Meta: model = Task fields = "__all__" \ No newline at end of file diff --git a/comrade/comrade_core/sse_render.py b/comrade/comrade_core/sse_render.py deleted file mode 100644 index e69de29..0000000 diff --git a/comrade/comrade_core/templates/login.html b/comrade/comrade_core/templates/login.html new file mode 100644 index 0000000..cbfef32 --- /dev/null +++ b/comrade/comrade_core/templates/login.html @@ -0,0 +1,75 @@ + + + + + + Login - Comrade + {% load socialaccount %} + + + +
+ + + {% if error %} +
{{ error }}
+ {% endif %} + + + Sign in with Google + +
+ + \ No newline at end of file diff --git a/comrade/comrade_core/templates/map.html b/comrade/comrade_core/templates/map.html index c1559e1..f6ed3cf 100644 --- a/comrade/comrade_core/templates/map.html +++ b/comrade/comrade_core/templates/map.html @@ -1,260 +1,1997 @@ - + - Comrade Map - - + + + Map - Comrade + + + + +
+
+

My Tasks

+
+ +
+
+
+

Logged in as:

+

Loading...

+

Loading...

+ + + +
+
+
+
+ Your Location +
+
+
+ Friends +
+
+
+ Public Shares +
+
+
+ Tasks +
+
+ + + + + +
+
+

Account Info

+

Name:

+

Email:

+
+

Location Sharing

+
+ + Enable Location Sharing +
+
+ + Share with Everyone (off = Friends Only) +
+
+
+
- - - -

User Location Map

-
\ No newline at end of file diff --git a/comrade/comrade_core/urls.py b/comrade/comrade_core/urls.py index a35b897..69cf8bc 100644 --- a/comrade/comrade_core/urls.py +++ b/comrade/comrade_core/urls.py @@ -2,22 +2,29 @@ from django.urls import re_path from . import consumers - from . import views urlpatterns = [ path('', views.index, name='index'), - path('google/', views.google, name='google'), path('user/', views.UserDetailView.as_view(), name='user_detail'), - path('user/token/', views.login_view, name='login'), path('map/', views.map, name='map'), path('task//start', views.TaskStartView.as_view(), name='start_task'), path('task//finish', views.TaskFinishView.as_view(), name='finish_task'), + path('task//pause', views.TaskPauseView.as_view(), name='pause_task'), + path('task//resume', views.TaskResumeView.as_view(), name='resume_task'), + path('task//debug_reset', views.TaskDebugResetView.as_view(), name='debug_reset_task'), path('tasks/', views.TaskListView.as_view(), name='task_list'), + path('friends/send//', views.send_friend_request, name='send_friend_request'), + path('friends/accept//', views.accept_friend_request, name='accept_friend_request'), + path('friends/reject//', views.reject_friend_request, name='reject_friend_request'), + path('friends/remove//', views.remove_friend, name='remove_friend'), + path('friends/', views.get_friends, name='get_friends'), + path('friends/pending/', views.get_pending_requests, name='get_pending_requests'), + path('friends/sent/', views.get_sent_requests, name='get_sent_requests'), + path('location/preferences/', views.LocationSharingPreferencesView.as_view(), name='location_preferences'), ] websocket_urlpatterns = [ re_path(r'ws/location/$', consumers.LocationConsumer.as_asgi()), re_path(r'ws/chat/(?P\w+)/$', consumers.ChatConsumer.as_asgi()), - ] diff --git a/comrade/comrade_core/views.py b/comrade/comrade_core/views.py index d5201ff..ff587a9 100644 --- a/comrade/comrade_core/views.py +++ b/comrade/comrade_core/views.py @@ -2,41 +2,84 @@ get_adapter as get_socialaccount_adapter, ) from comrade_core.models import Task -from allauth.socialaccount.models import SocialApp +from allauth.socialaccount.models import SocialApp, SocialAccount from django.contrib.auth import authenticate -from django.shortcuts import render +from django.shortcuts import render, redirect from rest_framework import generics, status from rest_framework.authtoken.models import Token -from rest_framework.decorators import api_view +from rest_framework.decorators import api_view, permission_classes from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter +from allauth.socialaccount.providers.oauth2.client import OAuth2Client +from allauth.socialaccount.providers.google.views import GoogleOAuth2Adapter +from allauth.socialaccount.providers.oauth2.views import OAuth2LoginView, OAuth2CallbackView +from django.contrib.auth import get_user_model +from allauth.socialaccount.providers.google.provider import GoogleProvider +from allauth.account.decorators import login_required from .serializers import UserDetailSerializer, TaskSerializer from django.core.exceptions import ValidationError +from .models import User + +from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie +from django.utils.decorators import method_decorator +import json +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync +from django.db import models + +User = get_user_model() def index(request): return render(request, "index.html") +@ensure_csrf_cookie +def login_page(request): + """Render the login page with Google sign-in""" + if request.user.is_authenticated: + return redirect('map') + return render(request, "login.html") + + def google(request): - try: - provider = get_socialaccount_adapter().get_provider(request, "google") - context = { - "client_id": provider.app.client_id, - } - except SocialApp.DoesNotExist: - context = { - "error": "Google social app not found. Check Sites in configuration.", - } - return render(request, "google.html", context=context) + return render(request, "google.html") +@ensure_csrf_cookie +@login_required def map(request): - return render(request, "map.html") + """Render the map page""" + # Create or get token + token, created = Token.objects.get_or_create(user=request.user) + + # Get friends list + friends = request.user.get_friends() + + # Prepare user data + user_data = { + 'id': request.user.id, + 'email': request.user.email, + 'name': f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username, + 'friends': [{'id': friend.id, 'name': f"{friend.first_name} {friend.last_name}".strip() or friend.username} for friend in friends], + 'skills': list(request.user.skills.values_list('name', flat=True)) + } + + # Set CSRF cookie + from django.middleware.csrf import get_token + get_token(request) + + context = { + 'api_token': token.key, + 'user': json.dumps(user_data) + } + + return render(request, "map.html", context=context) class UserDetailView(generics.RetrieveAPIView): @@ -44,21 +87,127 @@ class UserDetailView(generics.RetrieveAPIView): permission_classes = [IsAuthenticated] def get_object(self): - return self.request.user # Return the currently authenticated user + return self.request.user -@api_view(["POST"]) +@api_view(["GET"]) def login_view(request): - username = request.data.get("username") - password = request.data.get("password") - user = authenticate(username=username, password=password) - if user is not None: - token, created = Token.objects.get_or_create(user=user) - return Response({"token": token.key}, status=status.HTTP_200_OK) + """Redirect to login page""" + return redirect('login_page') + + +@csrf_exempt +@api_view(["GET", "POST"]) +def google_login_view(request): + """Handle Google OAuth login""" + if request.method == "GET": + # Handle the redirect from Google + credential = request.GET.get('credential') + if not credential: + return redirect('login_page') + + try: + # Get user info from Google + adapter = GoogleOAuth2Adapter(request) + client = OAuth2Client(request, adapter.client_id, adapter.client_secret, adapter.access_token_method, adapter.access_token_url, adapter.callback_url, adapter.scope) + user_info = client.get_user_info(credential) + + # Get or create user + try: + user = User.objects.get(email=user_info['email']) + except User.DoesNotExist: + # Create new user + user = User.objects.create_user( + username=user_info['email'], + email=user_info['email'], + first_name=user_info.get('given_name', ''), + last_name=user_info.get('family_name', '') + ) + + # Create or get social account + social_account, created = SocialAccount.objects.get_or_create( + provider=GoogleProvider.id, + uid=user_info['id'], + defaults={'user': user} + ) + if not created: + social_account.user = user + social_account.save() - return Response( - {"error": "Invalid Credentials"}, status=status.HTTP_401_UNAUTHORIZED - ) + # Create or get token + token, created = Token.objects.get_or_create(user=user) + + # Store token and user info in session + request.session['api_token'] = token.key + request.session['user'] = { + 'id': user.id, + 'email': user.email, + 'name': f"{user.first_name} {user.last_name}".strip() or user.username + } + + # Set CSRF cookie + from django.middleware.csrf import get_token + get_token(request) + + return redirect('map') + + except Exception as e: + print("Google login error:", str(e)) + return redirect('login_page') + + # Handle POST requests (if any) + access_token = request.data.get("access_token") + if not access_token: + return Response( + {"error": "Access token is required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + # Get user info from Google + adapter = GoogleOAuth2Adapter(request) + client = OAuth2Client(request, adapter.client_id, adapter.client_secret, adapter.access_token_method, adapter.access_token_url, adapter.callback_url, adapter.scope) + user_info = client.get_user_info(access_token) + + # Get or create user + try: + user = User.objects.get(email=user_info['email']) + except User.DoesNotExist: + # Create new user + user = User.objects.create_user( + username=user_info['email'], + email=user_info['email'], + first_name=user_info.get('given_name', ''), + last_name=user_info.get('family_name', '') + ) + + # Create or get social account + social_account, created = SocialAccount.objects.get_or_create( + provider=GoogleProvider.id, + uid=user_info['id'], + defaults={'user': user} + ) + if not created: + social_account.user = user + social_account.save() + + # Create or get token + token, created = Token.objects.get_or_create(user=user) + + return Response({ + "token": token.key, + "user": { + "id": user.id, + "email": user.email, + "name": f"{user.first_name} {user.last_name}".strip() or user.username + } + }, status=status.HTTP_200_OK) + + except Exception as e: + return Response( + {"error": str(e)}, + status=status.HTTP_401_UNAUTHORIZED + ) # POST /task/{taskId}/start @@ -103,14 +252,238 @@ def post(self, request: Request, taskId: int): status=status.HTTP_200_OK, ) +class TaskPauseView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request: Request, taskId: int): + task = None + try: + task = Task.objects.get(pk=taskId) + except Task.DoesNotExist as e: + return Response({"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND) + + try: + task.pause(request.user) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_412_PRECONDITION_FAILED) + + return Response( + {"message": "Task paused!"}, + status=status.HTTP_200_OK, + ) + +class TaskResumeView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request: Request, taskId: int): + task = None + try: + task = Task.objects.get(pk=taskId) + except Task.DoesNotExist as e: + return Response({"error": "Task not found"}, status=status.HTTP_404_NOT_FOUND) + + try: + task.resume(request.user) + except ValidationError as e: + return Response({"error": str(e)}, status=status.HTTP_412_PRECONDITION_FAILED) + + return Response( + {"message": "Task resumed!"}, + status=status.HTTP_200_OK, + ) + class TaskListView(APIView): permission_classes = [IsAuthenticated] def get(self, request): user = request.user - tasks = Task.objects.filter(skill_read__in=user.skills.all()) + # Get tasks that are: + # 1. IN_PROGRESS or WAITING and assigned to the current user + # 2. OPEN and user has the required execute skills + tasks = Task.objects.filter( + ( + models.Q(state=Task.State.IN_PROGRESS) | + models.Q(state=Task.State.WAITING) + ) & models.Q(assignee=user) | + ( + models.Q(state=Task.State.OPEN) & + models.Q(skill_execute__in=user.skills.all()) + ) + ).distinct() + + # For debugging: count tasks that have location data + tasks_with_location = tasks.exclude(lat__isnull=True).exclude(lon__isnull=True).count() + print(f"Found {tasks.count()} tasks for user {user} ({tasks_with_location} with location)") + serializer = TaskSerializer(tasks, many=True) return Response( {"tasks": serializer.data}, status=status.HTTP_200_OK, - ) \ No newline at end of file + ) + +class TaskDebugResetView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request: Request, taskId: int): + try: + task = Task.objects.get(pk=taskId) + except Task.DoesNotExist: + return Response( + {"error": "Task not found"}, + status=status.HTTP_404_NOT_FOUND + ) + + task.debug_reset() + return Response( + {"message": "Task reset to OPEN state"}, + status=status.HTTP_200_OK + ) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def send_friend_request(request, user_id): + try: + target_user = User.objects.get(id=user_id) + request.user.send_friend_request(target_user) + return Response({'status': 'Friend request sent'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def accept_friend_request(request, user_id): + try: + target_user = User.objects.get(id=user_id) + request.user.accept_friend_request(target_user) + + # Get channel layer for WebSocket communication + channel_layer = get_channel_layer() + + # Get both users' friends and skills + current_user_friends = request.user.get_friends() + target_user_friends = target_user.get_friends() + + # Prepare friend details messages for both users + current_user_details = { + 'type': 'friend_details', + 'userId': request.user.id, + 'name': f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username, + 'friends': [{'id': f.id, 'name': f"{f.first_name} {f.last_name}".strip() or f.username} for f in current_user_friends], + 'skills': list(request.user.skills.values_list('name', flat=True)) + } + + target_user_details = { + 'type': 'friend_details', + 'userId': target_user.id, + 'name': f"{target_user.first_name} {target_user.last_name}".strip() or target_user.username, + 'friends': [{'id': f.id, 'name': f"{f.first_name} {f.last_name}".strip() or f.username} for f in target_user_friends], + 'skills': list(target_user.skills.values_list('name', flat=True)) + } + + # Send friend details to both users + async_to_sync(channel_layer.group_send)( + f"location_{target_user.id}", + current_user_details + ) + async_to_sync(channel_layer.group_send)( + f"location_{request.user.id}", + target_user_details + ) + + return Response({'status': 'Friend request accepted'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def reject_friend_request(request, user_id): + try: + target_user = User.objects.get(id=user_id) + request.user.reject_friend_request(target_user) + return Response({'status': 'Friend request rejected'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +def remove_friend(request, user_id): + try: + target_user = User.objects.get(id=user_id) + request.user.remove_friend(target_user) + return Response({'status': 'Friend removed'}, status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response({'error': 'User not found'}, status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_friends(request): + friends = request.user.get_friends() + serializer = UserDetailSerializer(friends, many=True) + return Response({'friends': serializer.data}, status=status.HTTP_200_OK) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_pending_requests(request): + pending_requests = request.user.get_pending_friend_requests() + serializer = UserDetailSerializer(pending_requests, many=True) + return Response({'pending_requests': serializer.data}, status=status.HTTP_200_OK) + +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_sent_requests(request): + sent_requests = request.user.get_sent_friend_requests() + serializer = UserDetailSerializer(sent_requests, many=True) + return Response({'sent_requests': serializer.data}, status=status.HTTP_200_OK) + +@api_view(['GET']) +def get_user_info(request): + """Get user information after successful login""" + if not request.user.is_authenticated: + return Response( + {"error": "User not authenticated"}, + status=status.HTTP_401_UNAUTHORIZED + ) + + # Create or get token + token, created = Token.objects.get_or_create(user=request.user) + + return Response({ + "token": token.key, + "user": { + "id": request.user.id, + "email": request.user.email, + "name": f"{request.user.first_name} {request.user.last_name}".strip() or request.user.username + } + }, status=status.HTTP_200_OK) + +class LocationSharingPreferencesView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + """Get current location sharing preferences""" + preferences = request.user.get_location_sharing_preferences() + return Response(preferences, status=status.HTTP_200_OK) + + def post(self, request): + """Update location sharing preferences""" + sharing_level = request.data.get('sharing_level') + + if sharing_level not in dict(User.SharingLevel.choices): + return Response( + {"error": "Invalid sharing level"}, + status=status.HTTP_400_BAD_REQUEST + ) + + request.user.update_location_sharing_preferences(sharing_level=sharing_level) + + # Get updated preferences + preferences = request.user.get_location_sharing_preferences() + return Response(preferences, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/dump.rdb b/dump.rdb index 510a47b..718e67e 100644 Binary files a/dump.rdb and b/dump.rdb differ