diff --git a/admin/brands/forms.py b/admin/brands/forms.py index 942d4929338..05693c66a05 100644 --- a/admin/brands/forms.py +++ b/admin/brands/forms.py @@ -11,6 +11,7 @@ class Meta: widgets = { 'primary_color': TextInput(attrs={'class': 'colorpicker'}), 'secondary_color': TextInput(attrs={'class': 'colorpicker'}), + 'background_color': TextInput(attrs={'class': 'colorpicker'}), 'topnav_logo_image': TextInput(attrs={'placeholder': 'Logo should be max height of 40px', 'size': 200}), 'hero_logo_image': TextInput( attrs={'placeholder': 'Logo image should be max height of 100px', 'size': 200} diff --git a/admin/brands/views.py b/admin/brands/views.py index 01b449d3fe8..11473f86939 100644 --- a/admin/brands/views.py +++ b/admin/brands/views.py @@ -81,6 +81,7 @@ def post(self, request, *args, **kwargs): view = BrandChangeForm.as_view() primary_color = request.POST.get('primary_color') secondary_color = request.POST.get('secondary_color') + background_color = request.POST.get('background_color') if not is_a11y(primary_color): messages.warning(request, """The selected primary color is not a11y compliant. @@ -88,6 +89,9 @@ def post(self, request, *args, **kwargs): if not is_a11y(secondary_color): messages.warning(request, """The selected secondary color is not a11y compliant. For more information, visit https://color.a11y.com/""") + if background_color and not is_a11y(background_color): + messages.warning(request, """The selected background color is not a11y compliant. + For more information, visit https://color.a11y.com/""") return view(request, *args, **kwargs) @@ -109,6 +113,7 @@ def get_context_data(self, *args, **kwargs): def post(self, request, *args, **kwargs): primary_color = request.POST.get('primary_color') secondary_color = request.POST.get('secondary_color') + background_color = request.POST.get('background_color') if not is_a11y(primary_color): messages.warning(request, """The selected primary color is not a11y compliant. @@ -116,4 +121,7 @@ def post(self, request, *args, **kwargs): if not is_a11y(secondary_color): messages.warning(request, """The selected secondary color is not a11y compliant. For more information, visit https://color.a11y.com/""") + if background_color and not is_a11y(background_color): + messages.warning(request, """The selected background color is not a11y compliant. + For more information, visit https://color.a11y.com/""") return super().post(request, *args, **kwargs) diff --git a/api/brands/serializers.py b/api/brands/serializers.py index 8d040e7a93d..485d515b098 100644 --- a/api/brands/serializers.py +++ b/api/brands/serializers.py @@ -15,6 +15,7 @@ class BrandSerializer(JSONAPISerializer): primary_color = ser.CharField(read_only=True, max_length=7) secondary_color = ser.CharField(read_only=True, max_length=7) + background_color = ser.CharField(read_only=True, allow_null=True, max_length=7) links = LinksField({ 'self': 'get_absolute_url', diff --git a/api/nodes/permissions.py b/api/nodes/permissions.py index cf42b5a501e..0881a1ac627 100644 --- a/api/nodes/permissions.py +++ b/api/nodes/permissions.py @@ -128,6 +128,22 @@ def has_object_permission(self, request, view, obj): return obj.has_permission(auth.user, osf_permissions.WRITE) +class AdminOrWriteContributor(permissions.BasePermission): + acceptable_models = (AbstractNode, OSFUser, Institution, BaseAddonSettings, DraftRegistration) + + def has_object_permission(self, request, view, obj): + if isinstance(obj, dict) and 'self' in obj: + obj = obj['self'] + + assert_resource_type(obj, self.acceptable_models) + auth = get_user_auth(request) + + if request.method in permissions.SAFE_METHODS: + return obj.is_public or obj.can_view(auth) + + return obj.has_permission(auth.user, osf_permissions.ADMIN) or obj.has_permission(auth.user, osf_permissions.WRITE) + + class AdminOrPublic(permissions.BasePermission): acceptable_models = (AbstractNode, OSFUser, Institution, BaseAddonSettings, DraftRegistration) diff --git a/api/registrations/views.py b/api/registrations/views.py index b2026d5f4b8..2b1030d9629 100644 --- a/api/registrations/views.py +++ b/api/registrations/views.py @@ -59,6 +59,7 @@ AdminOrPublic, ExcludeWithdrawals, NodeLinksShowIfVersion, + AdminOrWriteContributor, ) from api.registrations.permissions import ContributorOrModerator, ContributorOrModeratorOrPublic from api.registrations.serializers import ( @@ -682,7 +683,7 @@ class RegistrationInstitutionsRelationship(NodeInstitutionsRelationship, Registr permission_classes = ( drf_permissions.IsAuthenticatedOrReadOnly, base_permissions.TokenHasScope, - AdminOrPublic, + AdminOrWriteContributor, ) diff --git a/api/users/views.py b/api/users/views.py index 6387bcbcea9..a0ea1e171ee 100644 --- a/api/users/views.py +++ b/api/users/views.py @@ -897,7 +897,6 @@ def get(self, request, *args, **kwargs): ) return Response(status=status.HTTP_200_OK, data={'message': status_message, 'kind': kind, 'institutional': institutional}) - @method_decorator(csrf_protect) def post(self, request, *args, **kwargs): serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/api_tests/registrations/views/test_registration_relationship_institutions.py b/api_tests/registrations/views/test_registration_relationship_institutions.py index b1a482c914a..07d71b3ca48 100644 --- a/api_tests/registrations/views/test_registration_relationship_institutions.py +++ b/api_tests/registrations/views/test_registration_relationship_institutions.py @@ -40,11 +40,11 @@ def resource_factory(self): return RegistrationFactory # test override, write contribs can't update institution - def test_put_not_admin_but_affiliated(self, app, institution_one, node, node_institutions_url): + def test_put_not_admin_but_affiliated_read_permission(self, app, institution_one, node, node_institutions_url): user = AuthUserFactory() user.add_or_update_affiliated_institution(institution_one) user.save() - node.add_contributor(user) + node.add_contributor(user, permissions=permissions.READ) node.save() res = app.put_json_api( @@ -58,7 +58,25 @@ def test_put_not_admin_but_affiliated(self, app, institution_one, node, node_ins assert res.status_code == 403 assert institution_one not in node.affiliated_institutions.all() - # test override, write contribs cannot delete + def test_put_not_admin_but_affiliated_and_write_permission(self, app, institution_one, node, node_institutions_url): + user = AuthUserFactory() + user.add_or_update_affiliated_institution(institution_one) + user.save() + node.add_contributor(user) + node.save() + + res = app.put_json_api( + node_institutions_url, + self.create_payload([institution_one]), + expect_errors=True, + auth=user.auth + ) + + node.reload() + assert res.status_code == 200 + assert institution_one in node.affiliated_institutions.all() + + # test override, write contribs can delete def test_delete_user_is_read_write(self, app, institution_one, node, node_institutions_url): user = AuthUserFactory() user.add_or_update_affiliated_institution(institution_one) @@ -74,7 +92,7 @@ def test_delete_user_is_read_write(self, app, institution_one, node, node_instit expect_errors=True ) - assert res.status_code == 403 + assert res.status_code == 204 def test_read_write_contributor_can_add_affiliated_institution( self, app, write_contrib, write_contrib_institution, node, node_institutions_url): @@ -91,8 +109,8 @@ def test_read_write_contributor_can_add_affiliated_institution( auth=write_contrib.auth, expect_errors=True ) - assert res.status_code == 403 - assert write_contrib_institution not in node.affiliated_institutions.all() + assert res.status_code == 201 + assert write_contrib_institution in node.affiliated_institutions.all() def test_read_write_contributor_can_remove_affiliated_institution( self, app, write_contrib, write_contrib_institution, node, node_institutions_url): @@ -111,8 +129,8 @@ def test_read_write_contributor_can_remove_affiliated_institution( auth=write_contrib.auth, expect_errors=True ) - assert res.status_code == 403 - assert write_contrib_institution in node.affiliated_institutions.all() + assert res.status_code == 204 + assert write_contrib_institution not in node.affiliated_institutions.all() def test_user_with_institution_and_permissions_through_patch(self, app, user, institution_one, institution_two, node, node_institutions_url): diff --git a/api_tests/users/views/test_user_settings.py b/api_tests/users/views/test_user_settings.py index cd4e25ff654..80f75303cdf 100644 --- a/api_tests/users/views/test_user_settings.py +++ b/api_tests/users/views/test_user_settings.py @@ -211,8 +211,7 @@ def test_get_invalid_email(self, app, url): assert res.status_code == 200 assert not mock_send_mail.called - def test_post(self, app, url, user_one, csrf_token): - app.set_cookie(CSRF_COOKIE_NAME, csrf_token) + def test_post(self, app, url, user_one): encoded_email = urllib.parse.quote(user_one.email) url = f'{url}?email={encoded_email}' res = app.get(url) @@ -227,7 +226,7 @@ def test_post(self, app, url, user_one, csrf_token): } } - res = app.post_json_api(url, payload, headers={'X-CSRFToken': csrf_token}) + res = app.post_json_api(url, payload) user_one.reload() assert res.status_code == 200 assert user_one.check_password('password2') diff --git a/osf/management/commands/force_archive.py b/osf/management/commands/force_archive.py index e2667325c15..1f5612a2f91 100644 --- a/osf/management/commands/force_archive.py +++ b/osf/management/commands/force_archive.py @@ -36,7 +36,7 @@ from addons.osfstorage.models import OsfStorageFile, OsfStorageFolder, OsfStorageFileNode from framework import sentry from framework.exceptions import HTTPError -from osf.models import AbstractNode, Node, NodeLog, Registration, BaseFileNode +from osf.models import Node, NodeLog, Registration, BaseFileNode from osf.models.files import TrashedFileNode from osf.exceptions import RegistrationStuckRecoverableException, RegistrationStuckBrokenException from api.base.utils import waterbutler_api_url_for @@ -285,32 +285,33 @@ def get_file_obj_from_log(log, reg): try: return BaseFileNode.objects.get(_id=log.params['urls']['view'].split('/')[4]) except KeyError: - path = log.params.get('path', '').split('/') - if log.action in ['addon_file_moved', 'addon_file_renamed']: + if log.action == 'osf_storage_folder_created': + return OsfStorageFolder.objects.get( + target_object_id=reg.registered_from.id, + name=log.params['path'].split('/')[-2] + ) + elif log.action == 'osf_storage_file_removed': + path = log.params['path'].split('/') + return TrashedFileNode.objects.get( + target_object_id=reg.registered_from.id, + name=path[-1] or path[-2] # file name or folder name + ) + elif log.action in ['addon_file_moved', 'addon_file_renamed']: try: return BaseFileNode.objects.get(_id=log.params['source']['path'].rstrip('/').split('/')[-1]) except (KeyError, BaseFileNode.DoesNotExist): return BaseFileNode.objects.get(_id=log.params['destination']['path'].rstrip('/').split('/')[-1]) - elif log.action == 'osf_storage_file_removed': - candidates = BaseFileNode.objects.filter( - target_object_id=reg.registered_from.id, - target_content_type_id=ContentType.objects.get_for_model(AbstractNode).id, - name=path[-1] or path[-2], - deleted_on__lte=log.date - ).order_by('-deleted_on') else: # Generic fallback - candidates = BaseFileNode.objects.filter( - target_object_id=reg.registered_from.id, - target_content_type_id=ContentType.objects.get_for_model(AbstractNode).id, - name=path[-1] or path[-2], - created__lte=log.date - ).order_by('-created') - - if candidates.exists(): - return candidates.first() + path = log.params.get('path', '').split('/') + if len(path) >= 2: + name = path[-1] or path[-2] # file name or folder name + return BaseFileNode.objects.get( + target_object_id=reg.registered_from.id, + name=name + ) - raise BaseFileNode.DoesNotExist(f"No file found for name '{path[-1] or path[-2]}' before {log.date}") + raise ValueError(f'Cannot determine file obj for log {log._id} [Registration id {reg._id}]: {log.action}') def handle_file_operation(file_tree, reg, file_obj, log, obj_cache): diff --git a/osf/migrations/0032_brand_background_color.py b/osf/migrations/0032_brand_background_color.py new file mode 100644 index 00000000000..9b465e81e4a --- /dev/null +++ b/osf/migrations/0032_brand_background_color.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.15 on 2025-08-12 12:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('osf', '0031_abstractprovider_registration_word'), + ] + + operations = [ + migrations.AddField( + model_name='brand', + name='background_color', + field=models.CharField(blank=True, max_length=7, null=True), + ), + ] diff --git a/osf/models/brand.py b/osf/models/brand.py index ae4650407aa..5a857f5f3b1 100644 --- a/osf/models/brand.py +++ b/osf/models/brand.py @@ -23,6 +23,7 @@ class Meta: primary_color = models.CharField(max_length=7) secondary_color = models.CharField(max_length=7) + background_color = models.CharField(max_length=7, blank=True, null=True) def __str__(self): return f'{self.name} ({self.id})' diff --git a/osf_tests/factories.py b/osf_tests/factories.py index 7ad8885e1ad..ea1ab95d437 100644 --- a/osf_tests/factories.py +++ b/osf_tests/factories.py @@ -1299,6 +1299,7 @@ class Meta: primary_color = factory.Faker('hex_color') secondary_color = factory.Faker('hex_color') + background_color = factory.Faker('hex_color') class SchemaResponseFactory(DjangoModelFactory): diff --git a/osf_tests/management_commands/test_force_archive.py b/osf_tests/management_commands/test_force_archive.py index 916b68b2cb8..cdd134a02d3 100644 --- a/osf_tests/management_commands/test_force_archive.py +++ b/osf_tests/management_commands/test_force_archive.py @@ -102,49 +102,6 @@ def test_generic_fallback(self, node, reg): file_obj = get_file_obj_from_log(log, reg) assert file_obj == file - @pytest.mark.django_db - def test_file_multiple_creations_deletions(self, node, reg): - file1 = OsfStorageFile.create(target=node, name='duplicate.txt') - file1.save() - file1.delete() - log1 = NodeLog.objects.create( - node=node, - action='osf_storage_file_removed', - params={'path': '/duplicate.txt'}, - date=timezone.now(), - ) - - file2 = OsfStorageFile.create(target=node, name='duplicate.txt') - file2.save() - file2.delete() - log2 = NodeLog.objects.create( - node=node, - action='osf_storage_file_removed', - params={'path': '/duplicate.txt'}, - date=timezone.now(), - ) - - file3 = OsfStorageFile.create(target=node, name='duplicate.txt') - file3.save() - log3 = NodeLog.objects.create( - node=node, - action='osf_storage_file_added', - params={'urls': {'view': f'/{node._id}/files/osfstorage/{file3._id}/'}}, - date=timezone.now(), - ) - - file_obj1 = get_file_obj_from_log(log1, reg) - assert file_obj1 == file1 - assert isinstance(file_obj1, TrashedFileNode) - - file_obj2 = get_file_obj_from_log(log2, reg) - assert file_obj2 == file2 - assert isinstance(file_obj2, TrashedFileNode) - - file_obj3 = get_file_obj_from_log(log3, reg) - assert file_obj3 == file3 - assert isinstance(file_obj3, OsfStorageFile) - class TestBuildFileTree: