Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -51,30 +51,30 @@ const DataRequestModal: FC<Props> = ({ 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,
Expand All @@ -93,23 +93,23 @@ const DataRequestModal: FC<Props> = ({ isOpen, onClose, post }) => {
include_user_data: true,
include_key_factors: false,
include_bots: undefined,
anonymized: !whitelistStatus.view_deanonymized_data,
anonymized: !dataAccessStatus.view_deanonymized_data,
},
});

const { minimize, include_bots, include_user_data } = watch();
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,
]);
Expand Down Expand Up @@ -225,7 +225,7 @@ const DataRequestModal: FC<Props> = ({ isOpen, onClose, post }) => {
disabled={isLoggedOut}
/>
) : null}
{whitelistStatus.is_whitelisted && (
{dataAccessStatus.has_data_access && (
<>
{include_user_data ? (
<div className="flex flex-col gap-1">
Expand Down Expand Up @@ -256,7 +256,7 @@ const DataRequestModal: FC<Props> = ({ isOpen, onClose, post }) => {
)}
</div>
) : null}
{whitelistStatus.view_deanonymized_data && include_user_data ? (
{dataAccessStatus.view_deanonymized_data && include_user_data ? (
<CheckboxField
control={control}
name="anonymized"
Expand Down
10 changes: 5 additions & 5 deletions front_end/src/services/api/posts/posts.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
PredictionFlowPost,
} from "@/types/post";
import { QuestionWithForecasts } from "@/types/question";
import { DataParams, Require, WhitelistStatus } from "@/types/utils";
import { DataAccessStatus, DataParams, Require } from "@/types/utils";
import { encodeQueryParams } from "@/utils/navigation";

export type PostsParams = PaginationParams & {
Expand Down Expand Up @@ -247,13 +247,13 @@ class PostsApi extends ApiService {
);
}

async getWhitelistStatus(params: {
async getDataAccessStatus(params: {
post_id?: number;
project_id?: number;
}): Promise<WhitelistStatus> {
}): Promise<DataAccessStatus> {
const queryParams = encodeQueryParams(params);
return await this.get<WhitelistStatus>(
`/get-whitelist-status/${queryParams}`
return await this.get<DataAccessStatus>(
`/get-data-access-status/${queryParams}`
);
}

Expand Down
4 changes: 2 additions & 2 deletions front_end/src/types/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
6 changes: 3 additions & 3 deletions misc/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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")
106 changes: 106 additions & 0 deletions misc/migrations/0008_whitelistuser_api_access_tier.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
43 changes: 31 additions & 12 deletions misc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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,
Expand All @@ -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.",
)

Expand Down
6 changes: 3 additions & 3 deletions misc/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
]
Loading
Loading