diff --git a/apiserver/plane/graphql/mutations/user/__init__.py b/apiserver/plane/graphql/mutations/user/__init__.py new file mode 100644 index 0000000000..832909a975 --- /dev/null +++ b/apiserver/plane/graphql/mutations/user/__init__.py @@ -0,0 +1,2 @@ +from .base import UserMutation +from .profile import ProfileMutation diff --git a/apiserver/plane/graphql/mutations/user.py b/apiserver/plane/graphql/mutations/user/base.py similarity index 64% rename from apiserver/plane/graphql/mutations/user.py rename to apiserver/plane/graphql/mutations/user/base.py index ee76e095cf..cc09a55a9b 100644 --- a/apiserver/plane/graphql/mutations/user.py +++ b/apiserver/plane/graphql/mutations/user/base.py @@ -10,7 +10,7 @@ from strawberry.permission import PermissionExtension from asgiref.sync import sync_to_async # Module imports -from plane.db.models import User, Profile, WorkspaceMember +from plane.db.models import User from plane.graphql.permissions.workspace import IsAuthenticated from plane.graphql.types.users import UserType @@ -48,26 +48,3 @@ class UserMutation: user = await sync_to_async(User.objects.get)(id=info.context.user.id) return user - - -@strawberry.type -class ProfileMutation: - @strawberry.mutation( - extensions=[PermissionExtension(permissions=[IsAuthenticated()])] - ) - async def update_last_workspace(self, info: Info, workspace: strawberry.ID) -> bool: - profile = await sync_to_async(Profile.objects.get)(user=info.context.user) - - # Wrap the synchronous call to `exists()` with `sync_to_async` - workspace_member_exists = await sync_to_async( - WorkspaceMember.objects.filter( - workspace=workspace, member=info.context.user - ).exists - )() - - if not workspace_member_exists: - return False - - profile.last_workspace_id = workspace - await sync_to_async(profile.save)() - return True diff --git a/apiserver/plane/graphql/mutations/user/profile.py b/apiserver/plane/graphql/mutations/user/profile.py new file mode 100644 index 0000000000..074aa79f2d --- /dev/null +++ b/apiserver/plane/graphql/mutations/user/profile.py @@ -0,0 +1,50 @@ +# python imports +from typing import Optional + +# Strawberry imports +import strawberry +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Third-party imports +from asgiref.sync import sync_to_async + +# Module imports +from plane.graphql.types.users import ProfileType +from plane.db.models import Profile, WorkspaceMember +from plane.graphql.permissions.workspace import IsAuthenticated + + +@strawberry.type +class ProfileMutation: + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[IsAuthenticated()])] + ) + async def update_last_workspace(self, info: Info, workspace: strawberry.ID) -> bool: + profile = await sync_to_async(Profile.objects.get)(user=info.context.user) + + workspace_member_exists = await sync_to_async( + WorkspaceMember.objects.filter( + workspace=workspace, member=info.context.user + ).exists + )() + + if not workspace_member_exists: + return False + + profile.last_workspace_id = workspace + await sync_to_async(profile.save)() + return True + + @strawberry.mutation( + extensions=[PermissionExtension(permissions=[IsAuthenticated()])] + ) + async def update_profile( + self, info: Info, mobile_timezone_auto_set: Optional[bool] = False + ) -> ProfileType: + profile = await sync_to_async(Profile.objects.get)(user=info.context.user) + + profile.mobile_timezone_auto_set = mobile_timezone_auto_set + + await sync_to_async(profile.save)() + return profile diff --git a/apiserver/plane/graphql/queries/dashboard.py b/apiserver/plane/graphql/queries/dashboard.py index 10426ded6b..15830e0aa2 100644 --- a/apiserver/plane/graphql/queries/dashboard.py +++ b/apiserver/plane/graphql/queries/dashboard.py @@ -59,5 +59,8 @@ class userInformationQuery: device_information = None return UserInformationType( - user=info.context.user, workspace=workspace, device_info=device_information + user=info.context.user, + profile=profile, + workspace=workspace, + device_info=device_information, ) diff --git a/apiserver/plane/graphql/queries/timezone.py b/apiserver/plane/graphql/queries/timezone.py new file mode 100644 index 0000000000..16623ccc08 --- /dev/null +++ b/apiserver/plane/graphql/queries/timezone.py @@ -0,0 +1,27 @@ +# Third-Party Imports +import strawberry + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.graphql.utils.timezone.base import get_timezone_list +from plane.graphql.types.timezone import TimezoneListType +from plane.graphql.permissions.workspace import IsAuthenticated + + +@strawberry.type +class TimezoneListQuery: + @strawberry.field(extensions=[PermissionExtension(permissions=[IsAuthenticated()])]) + async def timezone_list(self, info: Info) -> list[TimezoneListType]: + timezones = get_timezone_list() + + return [ + TimezoneListType( + value=timezone["value"], + query=timezone["query"], + label=timezone["label"], + ) + for timezone in timezones + ] diff --git a/apiserver/plane/graphql/queries/users/__init__.py b/apiserver/plane/graphql/queries/users/__init__.py new file mode 100644 index 0000000000..2c64963c2a --- /dev/null +++ b/apiserver/plane/graphql/queries/users/__init__.py @@ -0,0 +1,2 @@ +from .base import UserQuery, UserFavoritesQuery, UserRecentVisitQuery +from .profile import ProfileQuery diff --git a/apiserver/plane/graphql/queries/users.py b/apiserver/plane/graphql/queries/users/base.py similarity index 82% rename from apiserver/plane/graphql/queries/users.py rename to apiserver/plane/graphql/queries/users/base.py index c49edb134f..d59411ba88 100644 --- a/apiserver/plane/graphql/queries/users.py +++ b/apiserver/plane/graphql/queries/users/base.py @@ -13,13 +13,8 @@ from strawberry.types import Info from strawberry.permission import PermissionExtension # Module Imports -from plane.db.models import Profile, UserFavorite, UserRecentVisit -from plane.graphql.types.users import ( - UserType, - ProfileType, - UserFavoriteType, - UserRecentVisitType, -) +from plane.db.models import UserFavorite, UserRecentVisit +from plane.graphql.types.users import UserType, UserFavoriteType, UserRecentVisitType from plane.graphql.permissions.workspace import IsAuthenticated, WorkspaceBasePermission @@ -30,14 +25,6 @@ class UserQuery: return info.context.user -@strawberry.type -class ProfileQuery: - @strawberry.field(extensions=[PermissionExtension(permissions=[IsAuthenticated()])]) - async def profile(self, info: Info) -> ProfileType: - profile = await sync_to_async(Profile.objects.get)(user=info.context.user) - return profile - - # user favorite @strawberry.type class UserFavoritesQuery: diff --git a/apiserver/plane/graphql/queries/users/profile.py b/apiserver/plane/graphql/queries/users/profile.py new file mode 100644 index 0000000000..5d3306d8a3 --- /dev/null +++ b/apiserver/plane/graphql/queries/users/profile.py @@ -0,0 +1,22 @@ +# Third-Party Imports +import strawberry + +# Python Standard Library Imports +from asgiref.sync import sync_to_async + +# Strawberry Imports +from strawberry.types import Info +from strawberry.permission import PermissionExtension + +# Module Imports +from plane.db.models import Profile +from plane.graphql.types.users import ProfileType +from plane.graphql.permissions.workspace import IsAuthenticated + + +@strawberry.type +class ProfileQuery: + @strawberry.field(extensions=[PermissionExtension(permissions=[IsAuthenticated()])]) + async def profile(self, info: Info) -> ProfileType: + profile = await sync_to_async(Profile.objects.get)(user=info.context.user) + return profile diff --git a/apiserver/plane/graphql/schema.py b/apiserver/plane/graphql/schema.py index eee2c219e1..0d6c1031be 100644 --- a/apiserver/plane/graphql/schema.py +++ b/apiserver/plane/graphql/schema.py @@ -50,6 +50,7 @@ from .queries.dashboard import userInformationQuery from .queries.external import UnsplashImagesQuery, ProjectCoversQuery from .queries.feature_flag import FeatureFlagQuery from .queries.version_check import VersionCheckQuery +from .queries.timezone import TimezoneListQuery # mutations from .mutations.workspace import WorkspaceMutation, WorkspaceInviteMutation @@ -144,6 +145,7 @@ class Query( FeatureFlagQuery, VersionCheckQuery, WorkspacePageQuery, + TimezoneListQuery, ): pass diff --git a/apiserver/plane/graphql/types/dashboard.py b/apiserver/plane/graphql/types/dashboard.py index 14e0821caa..15bff48596 100644 --- a/apiserver/plane/graphql/types/dashboard.py +++ b/apiserver/plane/graphql/types/dashboard.py @@ -4,7 +4,7 @@ from typing import Optional import strawberry # module imports -from plane.graphql.types.users import UserType +from plane.graphql.types.users import UserType, ProfileType from plane.graphql.types.workspace import WorkspaceType from plane.graphql.types.device import DeviceInformationType @@ -12,5 +12,6 @@ from plane.graphql.types.device import DeviceInformationType @strawberry.type class UserInformationType: user: UserType + profile: ProfileType workspace: Optional[WorkspaceType] device_info: Optional[DeviceInformationType] diff --git a/apiserver/plane/graphql/types/timezone.py b/apiserver/plane/graphql/types/timezone.py new file mode 100644 index 0000000000..d282240cdd --- /dev/null +++ b/apiserver/plane/graphql/types/timezone.py @@ -0,0 +1,8 @@ +import strawberry + + +@strawberry.type +class TimezoneListType: + value: str + query: str + label: str diff --git a/apiserver/plane/graphql/types/users.py b/apiserver/plane/graphql/types/users.py index ccee89ec45..3318c54210 100644 --- a/apiserver/plane/graphql/types/users.py +++ b/apiserver/plane/graphql/types/users.py @@ -61,6 +61,9 @@ class ProfileType: billing_address: Optional[str] has_billing_address: bool company_name: str + mobile_timezone_auto_set: bool + is_mobile_onboarded: bool + mobile_onboarding_step = Optional[JSON] @strawberry.field def user(self) -> int: diff --git a/apiserver/plane/graphql/utils/timezone/base.py b/apiserver/plane/graphql/utils/timezone/base.py new file mode 100644 index 0000000000..8e08e63313 --- /dev/null +++ b/apiserver/plane/graphql/utils/timezone/base.py @@ -0,0 +1,267 @@ +# Python imports +import pytz +from datetime import datetime + + +def group_timezones(timezones): + grouped_map = {} + + for timezone in timezones: + key = timezone["value"] + + if key not in grouped_map: + grouped_map[key] = { + "utc_offset": timezone["utc_offset"], + "gmt_offset": timezone["gmt_offset"], + "value": timezone["value"], + "label": timezone["label"], + } + else: + existing = grouped_map[key] + existing["label"] = f"{existing['label']}, {timezone['label']}" + + return list(grouped_map.values()) + + +def get_timezone_list(): + timezone_mapping = { + "-1100": [ + ("Midway Island", "Pacific/Midway"), + ("American Samoa", "Pacific/Pago_Pago"), + ], + "-1000": [("Hawaii", "Pacific/Honolulu"), ("Aleutian Islands", "America/Adak")], + "-0930": [("Marquesas Islands", "Pacific/Marquesas")], + "-0900": [ + ("Alaska", "America/Anchorage"), + ("Gambier Islands", "Pacific/Gambier"), + ], + "-0800": [ + ("Pacific Time (US and Canada)", "America/Los_Angeles"), + ("Baja California", "America/Tijuana"), + ], + "-0700": [ + ("Mountain Time (US and Canada)", "America/Denver"), + ("Arizona", "America/Phoenix"), + ("Chihuahua, Mazatlan", "America/Chihuahua"), + ], + "-0600": [ + ("Central Time (US and Canada)", "America/Chicago"), + ("Saskatchewan", "America/Regina"), + ("Guadalajara, Mexico City, Monterrey", "America/Mexico_City"), + ("Tegucigalpa, Honduras", "America/Tegucigalpa"), + ("Costa Rica", "America/Costa_Rica"), + ], + "-0500": [ + ("Eastern Time (US and Canada)", "America/New_York"), + ("Lima", "America/Lima"), + ("Bogota", "America/Bogota"), + ("Quito", "America/Guayaquil"), + ("Chetumal", "America/Cancun"), + ], + "-0430": [("Caracas (Old Venezuela Time)", "America/Caracas")], + "-0400": [ + ("Atlantic Time (Canada)", "America/Halifax"), + ("Caracas", "America/Caracas"), + ("Santiago", "America/Santiago"), + ("La Paz", "America/La_Paz"), + ("Manaus", "America/Manaus"), + ("Georgetown", "America/Guyana"), + ("Bermuda", "Atlantic/Bermuda"), + ], + "-0330": [("Newfoundland Time (Canada)", "America/St_Johns")], + "-0300": [ + ("Buenos Aires", "America/Argentina/Buenos_Aires"), + ("Brasilia", "America/Sao_Paulo"), + ("Greenland", "America/Godthab"), + ("Montevideo", "America/Montevideo"), + ("Falkland Islands", "Atlantic/Stanley"), + ], + "-0200": [ + ("South Georgia and the South Sandwich Islands", "Atlantic/South_Georgia") + ], + "-0100": [ + ("Azores", "Atlantic/Azores"), + ("Cape Verde Islands", "Atlantic/Cape_Verde"), + ], + "+0000": [ + ("Dublin", "Europe/Dublin"), + ("Reykjavik", "Atlantic/Reykjavik"), + ("Lisbon", "Europe/Lisbon"), + ("Monrovia", "Africa/Monrovia"), + ("Casablanca", "Africa/Casablanca"), + ], + "+0100": [ + ("Central European Time (Berlin, Rome, Paris)", "Europe/Paris"), + ("West Central Africa", "Africa/Lagos"), + ("Algiers", "Africa/Algiers"), + ("Lagos", "Africa/Lagos"), + ("Tunis", "Africa/Tunis"), + ], + "+0200": [ + ("Eastern European Time (Cairo, Helsinki, Kyiv)", "Europe/Kiev"), + ("Athens", "Europe/Athens"), + ("Jerusalem", "Asia/Jerusalem"), + ("Johannesburg", "Africa/Johannesburg"), + ("Harare, Pretoria", "Africa/Harare"), + ], + "+0300": [ + ("Moscow Time", "Europe/Moscow"), + ("Baghdad", "Asia/Baghdad"), + ("Nairobi", "Africa/Nairobi"), + ("Kuwait, Riyadh", "Asia/Riyadh"), + ], + "+0330": [("Tehran", "Asia/Tehran")], + "+0400": [ + ("Abu Dhabi", "Asia/Dubai"), + ("Baku", "Asia/Baku"), + ("Yerevan", "Asia/Yerevan"), + ("Astrakhan", "Europe/Astrakhan"), + ("Tbilisi", "Asia/Tbilisi"), + ("Mauritius", "Indian/Mauritius"), + ], + "+0500": [ + ("Islamabad", "Asia/Karachi"), + ("Karachi", "Asia/Karachi"), + ("Tashkent", "Asia/Tashkent"), + ("Yekaterinburg", "Asia/Yekaterinburg"), + ("Maldives", "Indian/Maldives"), + ("Chagos", "Indian/Chagos"), + ], + "+0530": [ + ("Chennai", "Asia/Kolkata"), + ("Kolkata", "Asia/Kolkata"), + ("Mumbai", "Asia/Kolkata"), + ("New Delhi", "Asia/Kolkata"), + ("Sri Jayawardenepura", "Asia/Colombo"), + ], + "+0545": [("Kathmandu", "Asia/Kathmandu")], + "+0600": [ + ("Dhaka", "Asia/Dhaka"), + ("Almaty", "Asia/Almaty"), + ("Bishkek", "Asia/Bishkek"), + ("Thimphu", "Asia/Thimphu"), + ], + "+0630": [ + ("Yangon (Rangoon)", "Asia/Yangon"), + ("Cocos Islands", "Indian/Cocos"), + ], + "+0700": [ + ("Bangkok", "Asia/Bangkok"), + ("Hanoi", "Asia/Ho_Chi_Minh"), + ("Jakarta", "Asia/Jakarta"), + ("Novosibirsk", "Asia/Novosibirsk"), + ("Krasnoyarsk", "Asia/Krasnoyarsk"), + ], + "+0800": [ + ("Beijing", "Asia/Shanghai"), + ("Singapore", "Asia/Singapore"), + ("Perth", "Australia/Perth"), + ("Hong Kong", "Asia/Hong_Kong"), + ("Ulaanbaatar", "Asia/Ulaanbaatar"), + ("Palau", "Pacific/Palau"), + ], + "+0845": [("Eucla", "Australia/Eucla")], + "+0900": [ + ("Tokyo", "Asia/Tokyo"), + ("Seoul", "Asia/Seoul"), + ("Yakutsk", "Asia/Yakutsk"), + ], + "+0930": [("Adelaide", "Australia/Adelaide"), ("Darwin", "Australia/Darwin")], + "+1000": [ + ("Sydney", "Australia/Sydney"), + ("Brisbane", "Australia/Brisbane"), + ("Guam", "Pacific/Guam"), + ("Vladivostok", "Asia/Vladivostok"), + ("Tahiti", "Pacific/Tahiti"), + ], + "+1030": [("Lord Howe Island", "Australia/Lord_Howe")], + "+1100": [ + ("Solomon Islands", "Pacific/Guadalcanal"), + ("Magadan", "Asia/Magadan"), + ("Norfolk Island", "Pacific/Norfolk"), + ("Bougainville Island", "Pacific/Bougainville"), + ("Chokurdakh", "Asia/Srednekolymsk"), + ], + "+1200": [ + ("Auckland", "Pacific/Auckland"), + ("Wellington", "Pacific/Auckland"), + ("Fiji Islands", "Pacific/Fiji"), + ("Anadyr", "Asia/Anadyr"), + ], + "+1245": [("Chatham Islands", "Pacific/Chatham")], + "+1300": [("Nuku'alofa", "Pacific/Tongatapu"), ("Samoa", "Pacific/Apia")], + "+1400": [("Kiritimati Island", "Pacific/Kiritimati")], + } + + timezone_list = [] + now = datetime.now() + + # Process timezone mapping + for offset, locations in timezone_mapping.items(): + sign = "-" if offset.startswith("-") else "+" + hours = offset[1:3] + minutes = offset[3:] if len(offset) > 3 else "00" + + for friendly_name, tz_identifier in locations: + try: + tz = pytz.timezone(tz_identifier) + current_offset = now.astimezone(tz).strftime("%z") + + # converting and formatting UTC offset to GMT offset + current_utc_offset = now.astimezone(tz).utcoffset() + total_seconds = int(current_utc_offset.total_seconds()) + hours_offset = total_seconds // 3600 + minutes_offset = abs(total_seconds % 3600) // 60 + gmt_offset = ( + f"GMT{'+' if hours_offset >= 0 else '-'}" + f"{abs(hours_offset):02}:{minutes_offset:02}" + ) + + timezone_value = { + "offset": int(current_offset), + "utc_offset": f"UTC{sign}{hours}:{minutes}", + "gmt_offset": gmt_offset, + "value": tz_identifier, + "label": f"{friendly_name}", + } + + timezone_list.append(timezone_value) + except pytz.exceptions.UnknownTimeZoneError: + continue + + # Sort by offset and then by label + timezone_list.sort(key=lambda x: (x["offset"], x["label"])) + + # Remove offset from final output + for tz in timezone_list: + del tz["offset"] + + timezone_options_list = [ + { + "value": timezone["value"], + "query": ( + f"{timezone['value']} {timezone['label']}, " + f"{timezone['gmt_offset']}, {timezone['utc_offset']}" + ), + "label": timezone.get("label"), + } + for timezone in group_timezones(timezone_list) + ] + + # Add the additional static timezone_options_list + timezone_options_list.extend( + [ + { + "value": "UTC", + "query": "utc, coordinated universal time", + "label": "UTC", + }, + { + "value": "Universal", + "query": "universal, coordinated universal time", + "label": "Universal", + }, + ] + ) + + return timezone_options_list