-
Notifications
You must be signed in to change notification settings - Fork 0
Guest import #365
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jzgom067
wants to merge
71
commits into
v0.5.0
Choose a base branch
from
guest-account-import
base: v0.5.0
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Guest import #365
Changes from all commits
Commits
Show all changes
71 commits
Select commit
Hold shift + click to select a range
43cb44d
Create guest data summary endpoint
jzgom067 5fe2251
Create frontend mapping for guest data summary
jzgom067 2642833
Remove event creation participation filter
jzgom067 7b3f19a
Create initial guest import page
jzgom067 c5fc9c2
Change summary endpoint to give full data
jzgom067 2893a33
Add use client to client components
jzgom067 1f4ce8a
Add conflict indicator and information
jzgom067 df04288
Adjust guest data found text
jzgom067 3fc4821
Add mobile drawer conversion to FormDialog
jzgom067 9cd3eef
Remove unnecessary asNestedDrawer props
jzgom067 c6ffd9b
Add submitDisabled prop to FormDialog
jzgom067 d38882a
Update conflict banner
jzgom067 5e6967c
Create guest import dialog
jzgom067 53fab72
Add conflict resolution logic
jzgom067 d0a3a04
Simplify styling for conflict option
jzgom067 b39b118
Convert guest import review to page
jzgom067 820bee8
Overhaul guest data review layout
jzgom067 4dc3bc3
Update submission deletion message
jzgom067 1d8dde7
Add conflict banner to review page
jzgom067 0d0cf39
Create serializers file
jzgom067 54e1b71
Fix guest data endpoint response
jzgom067 ecc296c
Re-add guest data summary endpoint
jzgom067 51cb64b
Convert settings page to use summary
jzgom067 1bad28a
Simplify error checking
jzgom067 8b744e6
Simplify conflict resolution logic
jzgom067 69cf59a
Add none display for import data
jzgom067 1364989
Remove drawer nesting on conflict selector
jzgom067 7f4062d
Create import data endpoint
jzgom067 c8ee72a
Add backend functionality to guest review page
jzgom067 edf832f
Add tooltip to disabled submit button
jzgom067 4f6ef02
Move guest import to separate route
jzgom067 1e0732d
Add support for children in MessagePage
jzgom067 866a48e
Move guest data summary to component
jzgom067 7a457de
Create guest import login page and redirect
jzgom067 2636827
Create client page for login guest import
jzgom067 dc40d92
Add don't show again toggle and local storage
jzgom067 cdd7f32
Add don't show again to login redirect
jzgom067 686e4bb
Create loading page for import review
jzgom067 2c697f0
Adjust stroke width to match bold text
jzgom067 2bbc633
Add padding to login import page
jzgom067 ee7a757
Fix button activation area
jzgom067 089189a
Add guest-import to reserved keywords
jzgom067 9ee37fc
Add metadata to guest import pages
jzgom067 a9fb9cc
Rename and reroute guest import review page
jzgom067 2c2d5aa
Update dashboard guest banner text
jzgom067 4a708a6
Add public ID to UserEvent
jzgom067 3f317a0
Add UUIDField support to docs
jzgom067 cb5c955
Add public ID and remove null URL code filter
jzgom067 594d3d8
Add public ID to endpoint type mappings
jzgom067 13edb89
Fix UUID comparison
jzgom067 a759555
Add support for public ID and null URL code
jzgom067 c5d21d9
Add error handling for null URL code
jzgom067 e27390e
Add logging to guest import endpoints
jzgom067 e5b781e
Update guest mode banner text
jzgom067 35cccd4
Update guest summary wording
jzgom067 1397ca6
Add className support to SelectorDrawer
jzgom067 b85dcc0
Change mobile settings nav to dropdown
jzgom067 fc11d97
Update mobile loading skeleton
jzgom067 ae0d963
Update endpoint comment with errors
jzgom067 de5ddd4
Move session logic outside of try block
jzgom067 1fe1e79
Add select related call
jzgom067 ee9cb7b
Optimize conflict assignment
jzgom067 2fc7c97
Update docstring for import endpoint
jzgom067 e6244b5
Guard local storage read on server
jzgom067 493d660
Add unchecked don't show again logic
jzgom067 ee47898
Add aria-current to settings nav
jzgom067 e1762aa
Fix data get from request
jzgom067 f5e85b6
Add try-catch guards to localStorage access
jzgom067 0a470fb
Add actual error message to guest import
jzgom067 000f80a
Fix improper HTML semantics
jzgom067 4fdd22e
Remove parameter reassigning
jzgom067 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| from rest_framework import serializers | ||
|
|
||
|
|
||
| class GuestDataSummarySerializer(serializers.Serializer): | ||
| created_events = serializers.IntegerField() | ||
| participated_events = serializers.IntegerField() | ||
|
|
||
|
|
||
| class GuestEventSerializer(serializers.Serializer): | ||
| url_code = serializers.CharField(allow_null=True) | ||
| public_id = serializers.UUIDField() | ||
| title = serializers.CharField() | ||
|
|
||
|
|
||
| class GuestParticipationSerializer(serializers.Serializer): | ||
| url_code = serializers.CharField(allow_null=True) | ||
| public_id = serializers.UUIDField() | ||
| title = serializers.CharField() | ||
| guest_display_name = serializers.CharField() | ||
| account_display_name = serializers.CharField(allow_null=True) | ||
|
|
||
|
|
||
| class GuestDataSerializer(serializers.Serializer): | ||
| created_events = serializers.ListField( | ||
| child=GuestEventSerializer(), | ||
| allow_empty=True, | ||
| ) | ||
| participated_events = serializers.ListField( | ||
| child=GuestParticipationSerializer(), | ||
| allow_empty=True, | ||
| ) | ||
|
|
||
|
|
||
| class GuestImportDataSerializer(serializers.Serializer): | ||
| availability_choices = serializers.DictField( | ||
| required=True, | ||
| child=serializers.ChoiceField(required=True, choices=["guest", "account"]), | ||
| allow_empty=True, | ||
| ) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| from django.urls import path | ||
|
|
||
| from . import views | ||
|
|
||
| urlpatterns = [ | ||
| path("get-summary/", views.get_summary), | ||
| path("get-data/", views.get_data), | ||
| path("import-data/", views.import_data), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import logging | ||
|
|
||
| from django.db import transaction | ||
|
|
||
| from api.models import UserSession | ||
| from api.settings import GUEST_COOKIE_NAME | ||
| from api.utils import get_session, set_session_cookie, update_session_metadata | ||
|
|
||
| logger = logging.getLogger("api") | ||
|
|
||
|
|
||
| def get_guest_account(request, response): | ||
| """ | ||
| Returns the guest account associated with the request, refreshing the session token | ||
| cookie and updating the metadata on the session. | ||
|
|
||
| If there is no guest account, this will return `None`. | ||
|
|
||
| This does NOT make the guest user available on the `request` object. | ||
| """ | ||
| guest_token = request.COOKIES.get(GUEST_COOKIE_NAME) | ||
|
|
||
| if guest_token: | ||
| logger.debug("Guest session token: %s", guest_token) | ||
| try: | ||
| with transaction.atomic(): | ||
| session = get_session(guest_token) | ||
| if not session: | ||
| raise UserSession.DoesNotExist | ||
|
|
||
| update_session_metadata(request, session) | ||
| except UserSession.DoesNotExist: | ||
| logger.info("Guest session expired.") | ||
| else: | ||
| set_session_cookie(response, GUEST_COOKIE_NAME, session.session_token, True) | ||
| return session.user_account | ||
|
|
||
| return None | ||
|
jzgom067 marked this conversation as resolved.
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,231 @@ | ||
| import logging | ||
|
|
||
| from django.core.exceptions import ObjectDoesNotExist | ||
| from django.db import transaction | ||
| from rest_framework.response import Response | ||
|
|
||
| from api.decorators import ( | ||
| api_endpoint, | ||
| require_account_auth, | ||
| validate_json_input, | ||
| validate_output, | ||
| ) | ||
| from api.guest_import.serializers import ( | ||
| GuestDataSerializer, | ||
| GuestDataSummarySerializer, | ||
| GuestImportDataSerializer, | ||
| ) | ||
| from api.guest_import.utils import get_guest_account | ||
| from api.models import ( | ||
| EventParticipant, | ||
| UserEvent, | ||
| ) | ||
| from api.utils import MessageOutputSerializer | ||
|
|
||
| logger = logging.getLogger("api") | ||
|
|
||
|
|
||
| @api_endpoint("GET") | ||
| @require_account_auth | ||
| @validate_output(GuestDataSummarySerializer) | ||
| def get_summary(request): | ||
| """ | ||
| Returns counts of events created and participated in by the guest user. | ||
| """ | ||
| response = Response() | ||
|
|
||
| guest_user = get_guest_account(request, response) | ||
|
|
||
| if guest_user is None: | ||
| response.data = { | ||
| "created_events": 0, | ||
| "participated_events": 0, | ||
| } | ||
| response.status_code = 200 | ||
| return response | ||
|
|
||
| events = UserEvent.objects.filter(user_account=guest_user).count() | ||
|
|
||
| participations = EventParticipant.objects.filter(user_account=guest_user).count() | ||
|
|
||
| response.data = { | ||
| "created_events": events, | ||
| "participated_events": participations, | ||
| } | ||
| response.status_code = 200 | ||
|
|
||
| return response | ||
|
|
||
|
|
||
| @api_endpoint("GET") | ||
| @require_account_auth | ||
| @validate_output(GuestDataSerializer) | ||
| def get_data(request): | ||
| """ | ||
| Returns brief information about events created and participated in by the guest user. | ||
| """ | ||
| response = Response() | ||
|
|
||
| guest_user = get_guest_account(request, response) | ||
| account_user = request.user | ||
|
|
||
| if guest_user is None: | ||
| response.data = { | ||
| "created_events": [], | ||
| "participated_events": [], | ||
| } | ||
| response.status_code = 200 | ||
| return response | ||
|
|
||
| def get_url_code(event): | ||
| try: | ||
| return event.url_code.url_code | ||
| except ObjectDoesNotExist: | ||
| return None | ||
|
|
||
| events = UserEvent.objects.filter(user_account=guest_user).select_related( | ||
| "url_code" | ||
| ) | ||
| created_events = [ | ||
| { | ||
| "url_code": get_url_code(event), | ||
| "public_id": event.public_id, | ||
| "title": event.title, | ||
| } | ||
| for event in events | ||
| ] | ||
|
|
||
| participations = EventParticipant.objects.filter( | ||
| user_account=guest_user, | ||
| ).select_related("user_event__url_code") | ||
| participated_events = [ | ||
| { | ||
| "url_code": get_url_code(participation.user_event), | ||
| "public_id": participation.user_event.public_id, | ||
| "title": participation.user_event.title, | ||
| "guest_display_name": participation.display_name, | ||
| "account_display_name": None, | ||
| } | ||
| for participation in participations | ||
| ] | ||
|
|
||
| conflicts = EventParticipant.objects.filter( | ||
| user_account=account_user, | ||
| user_event__in=participations.values_list("user_event", flat=True), | ||
| ).select_related("user_event") | ||
| participation_map = {p["public_id"]: p for p in participated_events} | ||
| for conflict in conflicts: | ||
| participation = participation_map.get(conflict.user_event.public_id) | ||
| if participation: | ||
| participation["account_display_name"] = conflict.display_name | ||
| logger.info( | ||
| "%s conflicted events found during guest import for user: %s", | ||
| len(conflicts), | ||
| account_user.email, | ||
| ) | ||
|
|
||
| response.data = { | ||
| "created_events": created_events, | ||
| "participated_events": participated_events, | ||
| } | ||
| response.status_code = 200 | ||
|
|
||
| return response | ||
|
|
||
|
|
||
| @api_endpoint("POST") | ||
| @require_account_auth | ||
| @validate_json_input(GuestImportDataSerializer) | ||
| @validate_output(MessageOutputSerializer) | ||
| def import_data(request): | ||
| """ | ||
| Transfers ownership of events and availabilities created by the guest to the account. | ||
|
|
||
| The body of the request should contain a mapping of event's public ID to the user's | ||
| chosen submission to keep, for each event the guest had submitted availability to. If | ||
| there is no conflict, "guest" should be specified. | ||
|
|
||
| Every conflicted submission that is not kept will be deleted when calling this | ||
| endpoint. | ||
| """ | ||
| availability_choices = request.validated_data.get("availability_choices", {}) | ||
|
|
||
| response = Response() | ||
|
|
||
| guest_user = get_guest_account(request, response) | ||
| account_user = request.user | ||
|
|
||
| def no_data_found(): | ||
| response.data = { | ||
| "error": {"general": ["No guest data found."]}, | ||
| } | ||
| response.status_code = 400 | ||
| return response | ||
|
|
||
| if guest_user is None: | ||
| return no_data_found() | ||
|
|
||
| with transaction.atomic(): | ||
| # Check if the guest has any data to import | ||
| guest_events = UserEvent.objects.filter(user_account=guest_user) | ||
| guest_submissions = EventParticipant.objects.filter( | ||
| user_account=guest_user | ||
| ).select_related("user_event") | ||
| if not guest_events.exists() and not guest_submissions.exists(): | ||
| logger.warning( | ||
| "No guest data found for import attempt for user: %s", | ||
| account_user.email, | ||
| ) | ||
| return no_data_found() | ||
|
|
||
| # Check if all the guest user's submissions are accounted for in the choices | ||
| if set(availability_choices.keys()) != set( | ||
| str(submission.user_event.public_id) for submission in guest_submissions | ||
| ): | ||
| logger.warning( | ||
| "Availability choices do not match guest submissions for user: %s", | ||
| account_user.email, | ||
| ) | ||
| response.data = { | ||
| "error": { | ||
| "availability_choices": [ | ||
| "Availability choices do not match guest submissions.", | ||
| ] | ||
| } | ||
| } | ||
| response.status_code = 400 | ||
| return response | ||
|
|
||
| # Transfer event ownership | ||
| UserEvent.objects.filter(user_account=guest_user).update( | ||
| user_account=account_user | ||
| ) | ||
|
|
||
| # === DELETE DISCARDED SUBMISSIONS === | ||
| account_chosen = [] | ||
| guest_chosen = [] | ||
| for public_id, choice in availability_choices.items(): | ||
| match choice: | ||
| case "account": | ||
| account_chosen.append(public_id) | ||
| case "guest": | ||
| guest_chosen.append(public_id) | ||
|
|
||
| # Start by removing the guest's submissions from events that have "account" specified | ||
| EventParticipant.objects.filter( | ||
| user_account=guest_user, user_event__public_id__in=account_chosen | ||
| ).delete() | ||
| # Then remove the account's submissions from events that have "guest" specified | ||
| EventParticipant.objects.filter( | ||
| user_account=account_user, user_event__public_id__in=guest_chosen | ||
| ).delete() | ||
|
|
||
| # Then of all the remaining guest submissions transfer ownership to the account | ||
| EventParticipant.objects.filter(user_account=guest_user).update( | ||
| user_account=account_user | ||
| ) | ||
|
|
||
| response.data = {"message": ["Guest data imported successfully."]} | ||
| response.status_code = 200 | ||
|
|
||
| return response |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| # Generated by Django 5.2 on 2026-07-09 17:46 | ||
|
|
||
| import uuid | ||
|
|
||
| from django.db import migrations, models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("api", "0033_urlcode_unique_case_insensitive_url_code"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="userevent", | ||
| name="public_id", | ||
| field=models.UUIDField(default=uuid.uuid4, editable=False, null=True), | ||
| ), | ||
| ] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| # Generated by Django 5.2 on 2026-07-09 17:47 | ||
|
|
||
| import uuid | ||
|
|
||
| from django.db import migrations | ||
|
|
||
|
|
||
| def gen_uuid(apps, _): | ||
| UserEvent = apps.get_model("api", "UserEvent") | ||
| for row in UserEvent.objects.iterator(): | ||
| row.public_id = uuid.uuid4() | ||
| row.save(update_fields=["public_id"]) | ||
|
jzgom067 marked this conversation as resolved.
|
||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("api", "0034_userevent_public_id"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), | ||
| ] | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.