diff --git a/front_end/src/app/(main)/questions/[id]/components/download_question_data_modal/index.tsx b/front_end/src/app/(main)/questions/[id]/components/download_question_data_modal/index.tsx index c7294fbc92..d05501544e 100644 --- a/front_end/src/app/(main)/questions/[id]/components/download_question_data_modal/index.tsx +++ b/front_end/src/app/(main)/questions/[id]/components/download_question_data_modal/index.tsx @@ -51,30 +51,30 @@ const DataRequestModal: FC = ({ isOpen, onClose, post }) => { const { user } = useAuth(); const isLoggedOut = !user; - const [whitelistStatus, setWhitelistStatus] = useState({ - is_whitelisted: false, + const [dataAccessStatus, setDataAccessStatus] = useState({ + has_data_access: false, view_deanonymized_data: false, isLoaded: false, }); useEffect(() => { - if (!isOpen || whitelistStatus.isLoaded) { + if (!isOpen || dataAccessStatus.isLoaded) { return; } - const fetchWhitelistStatus = async () => { + const fetchDataAccessStatus = async () => { try { - const status = await ClientPostsApi.getWhitelistStatus({ + const status = await ClientPostsApi.getDataAccessStatus({ post_id: post.id, }); - setWhitelistStatus({ ...status, isLoaded: true }); + setDataAccessStatus({ ...status, isLoaded: true }); } catch (error) { - console.error("Error fetching whitelist status:", error); + console.error("Error fetching data access status:", error); // Set as loaded even on error to avoid infinite retries - setWhitelistStatus((prev) => ({ ...prev, isLoaded: true })); + setDataAccessStatus((prev) => ({ ...prev, isLoaded: true })); } }; - fetchWhitelistStatus(); - }, [isOpen, whitelistStatus.isLoaded, post.id]); + fetchDataAccessStatus(); + }, [isOpen, dataAccessStatus.isLoaded, post.id]); const { control, @@ -93,7 +93,7 @@ const DataRequestModal: FC = ({ isOpen, onClose, post }) => { include_user_data: true, include_key_factors: false, include_bots: undefined, - anonymized: !whitelistStatus.view_deanonymized_data, + anonymized: !dataAccessStatus.view_deanonymized_data, }, }); @@ -101,15 +101,15 @@ const DataRequestModal: FC = ({ isOpen, onClose, post }) => { const isDownloadDisabled = !minimize || !isNil(include_bots); useEffect(() => { - if (whitelistStatus.isLoaded) { + if (dataAccessStatus.isLoaded) { reset({ ...watch(), - anonymized: !whitelistStatus.view_deanonymized_data, + anonymized: !dataAccessStatus.view_deanonymized_data, }); } }, [ - whitelistStatus.isLoaded, - whitelistStatus.view_deanonymized_data, + dataAccessStatus.isLoaded, + dataAccessStatus.view_deanonymized_data, watch, reset, ]); @@ -225,7 +225,7 @@ const DataRequestModal: FC = ({ isOpen, onClose, post }) => { disabled={isLoggedOut} /> ) : null} - {whitelistStatus.is_whitelisted && ( + {dataAccessStatus.has_data_access && ( <> {include_user_data ? (
@@ -256,7 +256,7 @@ const DataRequestModal: FC = ({ isOpen, onClose, post }) => { )}
) : null} - {whitelistStatus.view_deanonymized_data && include_user_data ? ( + {dataAccessStatus.view_deanonymized_data && include_user_data ? ( { + }): Promise { const queryParams = encodeQueryParams(params); - return await this.get( - `/get-whitelist-status/${queryParams}` + return await this.get( + `/get-data-access-status/${queryParams}` ); } diff --git a/front_end/src/types/utils.ts b/front_end/src/types/utils.ts index 2ef06e4887..33305a459c 100644 --- a/front_end/src/types/utils.ts +++ b/front_end/src/types/utils.ts @@ -17,7 +17,7 @@ export type DataParams = { include_key_factors?: boolean; anonymized?: boolean; }; -export type WhitelistStatus = { - is_whitelisted: boolean; +export type DataAccessStatus = { + has_data_access: boolean; view_deanonymized_data: boolean; }; diff --git a/misc/admin.py b/misc/admin.py index 345b704987..1cb3ecd1fe 100644 --- a/misc/admin.py +++ b/misc/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from django.core.exceptions import ValidationError -from .models import Bulletin, SidebarItem, WhitelistUser +from .models import Bulletin, SidebarItem, UserDataAccess @admin.register(Bulletin) @@ -80,8 +80,8 @@ def content_type(self, obj: SidebarItem) -> str: return "" -@admin.register(WhitelistUser) -class WhitelistUserAdmin(admin.ModelAdmin): +@admin.register(UserDataAccess) +class UserDataAccessAdmin(admin.ModelAdmin): list_display = ("user", "created_at", "project", "post") search_fields = ("user__username", "user__email", "project__name", "post__title") autocomplete_fields = ("user", "project", "post") diff --git a/misc/migrations/0008_whitelistuser_api_access_tier.py b/misc/migrations/0008_whitelistuser_api_access_tier.py new file mode 100644 index 0000000000..4cc14a5127 --- /dev/null +++ b/misc/migrations/0008_whitelistuser_api_access_tier.py @@ -0,0 +1,106 @@ +# Generated by Django 5.1.15 on 2026-03-16 15:16 + +from django.db import migrations, models + + +def set_existing_view_user_data(apps, schema_editor): + """All pre-existing entries were created for user data access, so set view_user_data=True.""" + UserDataAccess = apps.get_model("misc", "UserDataAccess") + UserDataAccess.objects.all().update(view_user_data=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ("misc", "0007_bulletin_post_bulletin_project"), + ] + + operations = [ + migrations.RenameModel( + old_name="WhitelistUser", + new_name="UserDataAccess", + ), + migrations.AddField( + model_name="userdataaccess", + name="api_access_tier", + field=models.CharField( + choices=[ + ("restricted", "Restricted"), + ("benchmarking", "Benchmarking"), + ("unrestricted", "Unrestricted"), + ], + default="restricted", + help_text="Indicates the API access tier relevant to this data access entry.", + max_length=32, + ), + ), + migrations.AddField( + model_name="userdataaccess", + name="view_user_data", + field=models.BooleanField( + default=False, + help_text=( + "If True, the user can view user-level data (e.g., download datasets " + "with user-level information included). If False, the user can only access " + "aggregated data or anonymized user-level data." + ), + ), + ), + migrations.RunPython( + set_existing_view_user_data, + migrations.RunPython.noop, + ), + migrations.AlterField( + model_name="userdataaccess", + name="user", + field=models.ForeignKey( + on_delete=models.deletion.CASCADE, + related_name="data_accesses", + to="users.user", + ), + ), + migrations.AlterField( + model_name="userdataaccess", + name="project", + field=models.ForeignKey( + blank=True, + help_text=( + "Optional. Scopes this entry to a specific project. " + "If neither project nor post is set while `view_user_data` is True, this entry " + "will apply globally with respect to viewing user data. " + "The API access tier will apply to this project if it exceeds the user's " + "base tier. If neither project nor post is set, the api_access_tier will be " + "taken from the User's base tier." + ), + null=True, + on_delete=models.deletion.CASCADE, + related_name="data_accesses", + to="projects.project", + ), + ), + migrations.AlterField( + model_name="userdataaccess", + name="post", + field=models.ForeignKey( + blank=True, + help_text=( + "Optional. Scopes this entry to a specific post. " + "The API access tier will apply to this post if it exceeds the user's " + "base tier. If neither project nor post is set, the entry applies globally." + ), + null=True, + on_delete=models.deletion.CASCADE, + related_name="data_accesses", + to="posts.post", + ), + ), + migrations.AlterField( + model_name="userdataaccess", + name="notes", + field=models.TextField( + blank=True, + help_text="Optional notes about the data access grant, e.g., reason for access. Please note any specific conditions.", + null=True, + ), + ), + ] diff --git a/misc/models.py b/misc/models.py index 8bf1718bb9..db2e57747d 100644 --- a/misc/models.py +++ b/misc/models.py @@ -4,6 +4,7 @@ from posts.models import Post from projects.models import Project from users.models import User +from users.constants import ApiAccessTier from utils.models import TimeStampedModel @@ -74,29 +75,47 @@ class BulletinViewedBy(TimeStampedModel): user = models.ForeignKey(User, on_delete=models.CASCADE) -class WhitelistUser(TimeStampedModel): - """Whitelist for users for permission to download user-level data""" +class UserDataAccess(TimeStampedModel): + """Grants users permission to unlock project-specific API access and user-level data""" - user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="whitelists") + user = models.ForeignKey( + User, on_delete=models.CASCADE, related_name="data_accesses" + ) project = models.ForeignKey( Project, null=True, blank=True, on_delete=models.CASCADE, - related_name="whitelists", - help_text="Optional. If provided, this allows the user to download user-level " - "data for the project. If neither project nor post is set, the user is " - "whitelisted for all data.", + related_name="data_accesses", + help_text="Optional. Scopes this entry to a specific project. " + "If neither project nor post is set while `view_user_data` is True, this entry " + "will apply globally with respect to viewing user data. " + "The API access tier will apply to this project if it exceeds the user's " + "base tier. If neither project nor post is set, the api_access_tier will be " + "taken from the User's base tier.", ) post = models.ForeignKey( Post, null=True, blank=True, on_delete=models.CASCADE, - related_name="whitelists", - help_text="Optional. If provided, this allows the user to download user-level " - "data for the post. If neither project nor post is set, the user is " - "whitelisted for all data.", + related_name="data_accesses", + help_text="Optional. Scopes this entry to a specific post. " + "The API access tier will apply to this post if it exceeds the user's " + "base tier. If neither project nor post is set, the entry applies globally.", + ) + + api_access_tier = models.CharField( + max_length=32, + choices=ApiAccessTier.choices, + default=ApiAccessTier.RESTRICTED, + help_text="Indicates the API access tier relevant to this data access entry.", + ) + view_user_data = models.BooleanField( + default=False, + help_text="If True, the user can view user-level data (e.g., download datasets " + "with user-level information included). If False, the user can only access " + "aggregated data or anonymized user-level data.", ) view_deanonymized_data = models.BooleanField( default=False, @@ -105,7 +124,7 @@ class WhitelistUser(TimeStampedModel): notes = models.TextField( null=True, blank=True, - help_text="Optional notes about the whitelisting, e.g., reason for access. " + help_text="Optional notes about the data access grant, e.g., reason for access. " "Please note any specific conditions.", ) diff --git a/misc/urls.py b/misc/urls.py index 34170546f9..217a6ccd17 100644 --- a/misc/urls.py +++ b/misc/urls.py @@ -16,8 +16,8 @@ ), path("select2/", include("django_select2.urls")), path( - "get-whitelist-status/", - views.get_whitelist_status_api_view, - name="get-whitelist-status", + "get-data-access-status/", + views.get_data_access_status_api_view, + name="get-data-access-status", ), ] diff --git a/misc/utils.py b/misc/utils.py index c4de5fc2b7..6f7b412d5f 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -3,34 +3,38 @@ from django.db.models import Q from posts.models import Post -from projects.models import ObjectPermission, ProjectUserPermission +from projects.models import ObjectPermission, ProjectUserPermission, Project from users.models import User -def get_whitelist_status( +def get_data_access_status( user: User | None, post_id: int | None, project_id: int | None ): - # returns the most permissive whitelist status for the user + # returns the most permissive data access status for the user # Note: if user is admin for given post or project, - # they are considered fully whitelisted + # they are considered to have full data access if not user: return False, False if user.is_superuser or user.is_staff: - # staff users are always whitelisted return True, True project = None - # start with universal whitelistings - whitelistings = user.whitelists.filter(project__isnull=True, post__isnull=True) + user_data_accesses = user.data_accesses.filter(view_user_data=True) + # start with universal data access entries + data_access_entries = user_data_accesses.filter( + project__isnull=True, post__isnull=True + ) if post_id: post = get_object_or_404(Post, pk=post_id) project = post.default_project - whitelistings |= user.whitelists.filter(Q(project=project) | Q(post_id=post_id)) + data_access_entries |= user_data_accesses.filter( + Q(project=project) | Q(post_id=post_id) + ) if project_id: - project = get_object_or_404(Post, pk=project_id) - whitelistings |= user.whitelists.filter(project_id=project_id) + project = get_object_or_404(Project, pk=project_id) + data_access_entries |= user_data_accesses.filter(project_id=project_id) - # if user is admin for the project, they have whitelist status + # if user is admin for the project, they have data access if ( project and ProjectUserPermission.objects.filter( @@ -41,6 +45,8 @@ def get_whitelist_status( ): return True, True - is_whitelisted = whitelistings.exists() - view_deanonymized_data = whitelistings.filter(view_deanonymized_data=True).exists() - return is_whitelisted, view_deanonymized_data + has_data_access = data_access_entries.exists() + view_deanonymized_data = data_access_entries.filter( + view_deanonymized_data=True + ).exists() + return has_data_access, view_deanonymized_data diff --git a/misc/views.py b/misc/views.py index 75fc3b493c..ee04b69389 100644 --- a/misc/views.py +++ b/misc/views.py @@ -23,7 +23,7 @@ SidebarItemSerializer, ) from .services.itn import remove_article -from .utils import get_whitelist_status +from .utils import get_data_access_status @api_view(["POST"]) @@ -166,19 +166,19 @@ def sidebar_api_view(request: Request): @api_view(["GET"]) @permission_classes([AllowAny]) -def get_whitelist_status_api_view(request: Request): +def get_data_access_status_api_view(request: Request): data = request.query_params post_id = data.get("post_id") project_id = data.get("project_id") user = request.user if request.user.is_authenticated else None - is_whitelisted, view_deanonymized_data = get_whitelist_status( + has_data_access, view_deanonymized_data = get_data_access_status( user, post_id, project_id ) return Response( { - "is_whitelisted": is_whitelisted, + "has_data_access": has_data_access, "view_deanonymized_data": view_deanonymized_data, }, status=status.HTTP_200_OK, diff --git a/users/constants.py b/users/constants.py new file mode 100644 index 0000000000..388aae264c --- /dev/null +++ b/users/constants.py @@ -0,0 +1,7 @@ +from django.db import models + + +class ApiAccessTier(models.TextChoices): + RESTRICTED = "restricted", "Restricted" + BENCHMARKING = "benchmarking", "Benchmarking" + UNRESTRICTED = "unrestricted", "Unrestricted" diff --git a/users/migrations/0016_user_api_access_tier.py b/users/migrations/0016_user_api_access_tier.py index dd50f38ecd..625d948fcf 100644 --- a/users/migrations/0016_user_api_access_tier.py +++ b/users/migrations/0016_user_api_access_tier.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): field=models.CharField( choices=[ ("restricted", "Restricted"), - ("bot_benchmarking", "Bot Benchmarking"), + ("benchmarking", "Benchmarking"), ("unrestricted", "Unrestricted"), ], default="restricted", diff --git a/users/models.py b/users/models.py index e130bac2ef..82dd716b35 100644 --- a/users/models.py +++ b/users/models.py @@ -10,10 +10,12 @@ from django.utils import timezone from utils.models import TimeStampedModel +from users.constants import ApiAccessTier if TYPE_CHECKING: from comments.models import Comment from posts.models import Post + from misc.models import UserDataAccess class User(TimeStampedModel, AbstractUser): @@ -30,6 +32,7 @@ class InterfaceType(models.TextChoices): id: int comment_set: QuerySet["Comment"] posts: QuerySet["Post"] + data_accesses: QuerySet["UserDataAccess"] # Profile data bio = models.TextField(default="", blank=True) @@ -98,11 +101,6 @@ class InterfaceType(models.TextChoices): choices=settings.LANGUAGES, ) - class ApiAccessTier(models.TextChoices): - RESTRICTED = "restricted", "Restricted" - BOT_BENCHMARKING = "bot_benchmarking", "Bot Benchmarking" - UNRESTRICTED = "unrestricted", "Unrestricted" - api_access_tier = models.CharField( max_length=32, choices=ApiAccessTier.choices, diff --git a/users/serializers.py b/users/serializers.py index 6d48012e75..a5f83e4618 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -134,6 +134,21 @@ def get_has_password(self, user: User) -> bool: return user.has_usable_password() +class UserPrivateDataAccessSerializer(UserPrivateSerializer): + project_data_access = serializers.SerializerMethodField() + + class Meta(UserPrivateSerializer.Meta): + fields = UserPrivateSerializer.Meta.fields + ("project_data_access",) + + def get_project_data_access(self, user: User): + entries = ( + user.data_accesses.filter(project_id__isnull=False) + .values("project_id", "api_access_tier") + .distinct() + ) + return list(entries) + + class UserUpdateProfileSerializer(serializers.ModelSerializer): website = serializers.URLField(allow_blank=True, max_length=100) diff --git a/users/views.py b/users/views.py index 857a0a5769..f332621584 100644 --- a/users/views.py +++ b/users/views.py @@ -15,6 +15,7 @@ from users.models import User, UserSpamActivity from users.serializers import ( UserPrivateSerializer, + UserPrivateDataAccessSerializer, UserPublicSerializer, validate_username, UserUpdateProfileSerializer, @@ -59,8 +60,10 @@ def current_user_api_view(request): A lightweight profile data of the current user Should contain minimum profile data without heavy calcs """ - - return Response(UserPrivateSerializer(request.user).data) + if request.GET.get("with_data_access") == "true": + return Response(UserPrivateDataAccessSerializer(request.user).data) + else: + return Response(UserPrivateSerializer(request.user).data) @api_view(["GET"]) diff --git a/utils/csv_utils.py b/utils/csv_utils.py index 1157b7eb6c..b962785f63 100644 --- a/utils/csv_utils.py +++ b/utils/csv_utils.py @@ -120,7 +120,7 @@ def export_all_data_for_questions( def export_data_for_questions( user_id: int | None, is_staff: bool, - is_whitelisted: bool, + has_data_access: bool, question_ids: list[int], aggregation_methods: list[AggregationMethod] | None, minimize: bool, @@ -145,10 +145,10 @@ def export_data_for_questions( ) if only_include_user_ids: user_forecasts = user_forecasts.filter(author_id__in=only_include_user_ids) - if not (is_whitelisted or is_staff): + if not (has_data_access or is_staff): user_forecasts = user_forecasts.filter(author=user) - if is_whitelisted or is_staff: + if has_data_access or is_staff: questions_with_revealed_cp = questions else: questions_with_revealed_cp = questions.filter( @@ -226,7 +226,7 @@ def export_data_for_questions( archived_scores = archived_scores.filter( Q(user_id__in=only_include_user_ids) | Q(user__isnull=True) ) - elif not (is_whitelisted or is_staff): + elif not (has_data_access or is_staff): # only include user-specific scores for the logged-in user scores = scores.filter( Q(user__isnull=True) | (Q(user=user) if user else Q()) diff --git a/utils/serializers.py b/utils/serializers.py index 061ad6209d..1fb25a06e7 100644 --- a/utils/serializers.py +++ b/utils/serializers.py @@ -113,7 +113,7 @@ def validate_aggregation_methods(self, value: str | None): def validate_user_ids(self, user_ids: list[int]): if not user_ids: return user_ids - if not (self.context.get("is_staff") or self.context.get("is_whitelisted")): + if not (self.context.get("is_staff") or self.context.get("has_data_access")): raise serializers.ValidationError( "Current user cannot view user-specific data. " "Please remove user_ids parameter." @@ -199,7 +199,7 @@ def validate_aggregation_methods(self, methods: str | None): def validate_user_ids(self, user_ids: list[int]): if not user_ids: return user_ids - if not (self.context.get("is_staff") or self.context.get("is_whitelisted")): + if not (self.context.get("is_staff") or self.context.get("has_data_access")): raise serializers.ValidationError( "Current user cannot view user-specific data. " "Please remove user_ids parameter." diff --git a/utils/tasks.py b/utils/tasks.py index e5046f4a40..ccc2b6c0dc 100644 --- a/utils/tasks.py +++ b/utils/tasks.py @@ -87,7 +87,7 @@ def email_data_task( user_id: int, user_email: str, is_staff: bool, - is_whitelisted: bool, + has_data_access: bool, filename: str, question_ids: list[int], aggregation_methods: list[AggregationMethod], @@ -114,7 +114,7 @@ def email_data_task( data = export_data_for_questions( user_id=user_id, is_staff=is_staff, - is_whitelisted=is_whitelisted, + has_data_access=has_data_access, question_ids=question_ids, aggregation_methods=aggregation_methods, minimize=minimize, diff --git a/utils/views.py b/utils/views.py index 1169fa1914..2c5833487e 100644 --- a/utils/views.py +++ b/utils/views.py @@ -7,7 +7,7 @@ from rest_framework.request import Request from rest_framework.response import Response -from misc.models import WhitelistUser +from misc.models import UserDataAccess from posts.models import Post from posts.services.common import get_post_permission_for_user from projects.models import Project @@ -51,18 +51,19 @@ def aggregation_explorer_api_view(request) -> Response: # Context for the serializer is_staff = user.is_authenticated and user.is_staff - is_whitelisted = user.is_authenticated and ( - WhitelistUser.objects.filter( + has_data_access = user.is_authenticated and ( + UserDataAccess.objects.filter( Q(post=post) | Q(project=post.default_project) | (Q(post__isnull=True) & Q(project__isnull=True)), user=user, + view_user_data=True, ).exists() ) serializer_context = { "user": user if user.is_authenticated else None, "is_staff": is_staff, - "is_whitelisted": is_whitelisted, + "has_data_access": has_data_access, } serializer = DataGetRequestSerializer( @@ -157,17 +158,18 @@ def validate_data_request(request: Request, **kwargs): project_ids = [project.id] if project else [] if post: project_ids.extend(post.projects.values_list("id", flat=True)) - whitelistings = WhitelistUser.objects.filter( + data_access_entries = UserDataAccess.objects.filter( (Q(post=post) if post else Q()) | (Q(project_id__in=project_ids) if project_ids else Q()) | Q(project__isnull=True, post__isnull=True), user_id=user.id or 0, + view_user_data=True, ) - is_whitelisted = user.is_authenticated and whitelistings.exists() + has_data_access = user.is_authenticated and data_access_entries.exists() serializer_context = { "user": user if user.is_authenticated else None, "is_staff": is_staff, - "is_whitelisted": is_whitelisted, + "has_data_access": has_data_access, } serializer = DataPostRequestSerializer(data=data, context=serializer_context) @@ -187,8 +189,8 @@ def validate_data_request(request: Request, **kwargs): joined_before_date = params.get("joined_before_date") if is_staff: anonymized = params.get("anonymized", False) - elif is_whitelisted: - if whitelistings.filter(view_deanonymized_data=True).exists(): + elif has_data_access: + if data_access_entries.filter(view_deanonymized_data=True).exists(): anonymized = params.get("anonymized", False) else: anonymized = True @@ -225,7 +227,7 @@ def validate_data_request(request: Request, **kwargs): "user_id": user.id if user.is_authenticated else None, "user_email": user.email if user.is_authenticated else None, "is_staff": is_staff, - "is_whitelisted": is_whitelisted, + "has_data_access": has_data_access, "filename": filename, "question_ids": [q.id for q in questions], "aggregation_methods": aggregation_methods,