diff --git a/.travis.yml b/.travis.yml index 9c6d46cb7dd..ef28085b46f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,8 +4,9 @@ language: python python: - "2.7" -dist: trusty + sudo: false +dist: trusty # TODO: uncomment when https://github.com/travis-ci/travis-ci/issues/8836 is resolved # addons: diff --git a/CHANGELOG b/CHANGELOG index da08e0a854d..791c594bb10 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,21 @@ We follow the CalVer (https://calver.org/) versioning scheme: YY.MINOR.MICRO. +19.24.0 (2019-08-27) +=================== +- APIv2: Allow creating a node with a license attached on creation +- APIv2: Prevent creating a node link to the node itself, a parent, or a child +- APIv2: Have the move/copy WB hooks update storage usage +- Exclude collection groups from user page in Django's admin app +- Have osfstorage move/copy hooks recursively update the latest file version's region +- Fix password reset tokens +- Do not reveal entire google drive file path in node logs +- Allow logging in from password reset and forgot password pages +- Fix broken social links on add contributors modal +- Fix email typos in embargoed registration emails +- Improve registration and retraction node log display text +- Fix logs if cron job automatically approves a withdrawal + 19.23.0 (2019-08-19) =================== - Represents scopes as an m2m field on personal access tokens instead of a CharField diff --git a/addons/osfstorage/models.py b/addons/osfstorage/models.py index 54703208bee..43e521ed186 100644 --- a/addons/osfstorage/models.py +++ b/addons/osfstorage/models.py @@ -166,16 +166,16 @@ def delete(self, user=None, parent=None, **kwargs): self._materialized_path = self.materialized_path return super(OsfStorageFileNode, self).delete(user=user, parent=parent) if self._check_delete_allowed() else None + def update_region_from_latest_version(self, destination_parent): + raise NotImplementedError + def move_under(self, destination_parent, name=None): if self.is_preprint_primary: if self.target != destination_parent.target or self.provider != destination_parent.provider: raise exceptions.FileNodeIsPrimaryFile() if self.is_checked_out: raise exceptions.FileNodeCheckedOutError() - most_recent_fileversion = self.versions.select_related('region').order_by('-created').first() - if most_recent_fileversion and most_recent_fileversion.region != destination_parent.target.osfstorage_region: - most_recent_fileversion.region = destination_parent.target.osfstorage_region - most_recent_fileversion.save() + self.update_region_from_latest_version(destination_parent) return super(OsfStorageFileNode, self).move_under(destination_parent, name) def check_in_or_out(self, user, checkout, save=False): @@ -293,6 +293,12 @@ def serialize(self, include_full=None, version=None): }) return ret + def update_region_from_latest_version(self, destination_parent): + most_recent_fileversion = self.versions.select_related('region').order_by('-created').first() + if most_recent_fileversion and most_recent_fileversion.region != destination_parent.target.osfstorage_region: + most_recent_fileversion.region = destination_parent.target.osfstorage_region + most_recent_fileversion.save() + def create_version(self, creator, location, metadata=None): latest_version = self.get_version() version = FileVersion(identifier=self.versions.count() + 1, creator=creator, location=location) @@ -451,6 +457,9 @@ def serialize(self, include_full=False, version=None): ret['fullPath'] = self.materialized_path return ret + def update_region_from_latest_version(self, destination_parent): + for child in self.children.all().prefetch_related('versions'): + child.update_region_from_latest_version(destination_parent) class Region(models.Model): _id = models.CharField(max_length=255, db_index=True) diff --git a/addons/osfstorage/tests/test_models.py b/addons/osfstorage/tests/test_models.py index ff973e7801c..4e7fb592aed 100644 --- a/addons/osfstorage/tests/test_models.py +++ b/addons/osfstorage/tests/test_models.py @@ -334,6 +334,30 @@ def test_move_nested(self): assert_equal(new_project, move_to.target) assert_equal(new_project, child.target) + def test_move_nested_between_regions(self): + canada = RegionFactory() + new_component = NodeFactory(parent=self.project) + component_node_settings = new_component.get_addon('osfstorage') + component_node_settings.region = canada + component_node_settings.save() + + move_to = component_node_settings.get_root() + to_move = self.node_settings.get_root().append_folder('Aaah').append_folder('Woop') + child = to_move.append_file('There it is') + + for _ in range(2): + version = factories.FileVersionFactory(region=self.node_settings.region) + child.versions.add(version) + child.save() + + moved = to_move.move_under(move_to) + child.reload() + + assert new_component == child.target + versions = child.versions.order_by('-created') + assert versions.first().region == component_node_settings.region + assert versions.last().region == self.node_settings.region + def test_copy_rename(self): to_copy = self.node_settings.get_root().append_file('Carp') copy_to = self.node_settings.get_root().append_folder('Cloud') diff --git a/addons/wiki/tests/test_wiki.py b/addons/wiki/tests/test_wiki.py index bfcd65fc5ff..a60b5aaa03d 100644 --- a/addons/wiki/tests/test_wiki.py +++ b/addons/wiki/tests/test_wiki.py @@ -1238,7 +1238,6 @@ def test_serialize_wiki_settings(self): node = NodeFactory(parent=self.project, creator=self.user, is_public=True) node.get_addon('wiki').set_editing( permissions=True, auth=self.consolidate_auth, log=True) - node.add_pointer(self.project, Auth(self.user)) node.save() data = serialize_wiki_settings(self.user, [node]) expected = [{ diff --git a/admin_tests/base/test_utils.py b/admin_tests/base/test_utils.py index 89b8b843c33..3506c57534c 100644 --- a/admin_tests/base/test_utils.py +++ b/admin_tests/base/test_utils.py @@ -1,22 +1,30 @@ from nose.tools import * # noqa: F403 import datetime as datetime +import pytest +from django.test import RequestFactory from django.db.models import Q from django.contrib.auth.models import Group from django.core.exceptions import ValidationError, PermissionDenied +from django.contrib.admin.sites import AdminSite +from django.forms.models import model_to_dict +from django.http import QueryDict + from tests.base import AdminTestCase -from osf_tests.factories import SubjectFactory, UserFactory, RegistrationFactory +from osf_tests.factories import SubjectFactory, UserFactory, RegistrationFactory, PreprintFactory -from osf.models import Subject +from osf.models import Subject, OSFUser, Collection from osf.models.provider import rules_to_subjects from admin.base.utils import get_subject_rules, change_embargo_date +from osf.admin import OSFUserAdmin import logging logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) +pytestmark = pytest.mark.django_db class TestSubjectRules(AdminTestCase): @@ -158,3 +166,82 @@ def test_change_embargo_date(self): assert_almost_equal(self.registration.embargo.end_date, self.date_valid2, delta=datetime.timedelta(days=1)) # Add a test to check privatizing + +site = AdminSite() + +class TestGroupCollectionsPreprints: + @pytest.mark.enable_bookmark_creation + @pytest.fixture() + def user(self): + return UserFactory() + + @pytest.fixture() + def admin_url(self, user): + return '/admin/osf/osfuser/{}/change/'.format(user.id) + + @pytest.fixture() + def preprint(self, user): + return PreprintFactory(creator=user) + + @pytest.fixture() + def get_request(self, admin_url, user): + request = RequestFactory().get(admin_url) + request.user = user + return request + + @pytest.fixture() + def post_request(self, admin_url, user): + request = RequestFactory().post(admin_url) + request.user = user + return request + + @pytest.fixture() + def osf_user_admin(self): + return OSFUserAdmin(OSFUser, site) + + @pytest.mark.enable_bookmark_creation + def test_admin_app_formfield_collections(self, preprint, user, get_request, osf_user_admin): + """ Testing OSFUserAdmin.formfield_many_to_many. + This should not return any bookmark collections or preprint groups, even if the user is a member. + """ + + formfield = (osf_user_admin.formfield_for_manytomany(OSFUser.groups.field, request=get_request)) + queryset = formfield.queryset + + collections_group = Collection.objects.filter(creator=user, is_bookmark_collection=True)[0].get_group('admin') + assert(collections_group not in queryset) + + assert(preprint.get_group('admin') not in queryset) + + @pytest.mark.enable_bookmark_creation + def test_admin_app_save_related_collections(self, post_request, osf_user_admin, user, preprint): + """ Testing OSFUserAdmin.save_related + This should maintain the bookmark collections and preprint groups the user is a member of + even though they aren't explicitly returned by the form. + """ + + form = osf_user_admin.get_form(request=post_request, obj=user) + data_dict = model_to_dict(user) + post_form = form(data_dict, instance=user) + + # post_form.errors.keys() generates a list of fields causing JSON Related errors + # which are preventing the form from being valid (which is required for the form to be saved). + # By setting the field to '{}', this makes the form valid and resolves JSON errors. + + for field in post_form.errors.keys(): + if field == 'groups': + data_dict['groups'] = [] + else: + data_dict[field] = '{}' + post_form = form(data_dict, instance=user) + assert(post_form.is_valid()) + post_form.save(commit=False) + qdict = QueryDict('', mutable=True) + qdict.update(data_dict) + post_request.POST = qdict + osf_user_admin.save_related(request=post_request, form=post_form, formsets=[], change=True) + + collections_group = Collection.objects.filter(creator=user, is_bookmark_collection=True)[0].get_group('admin') + assert(collections_group in user.groups.all()) + + assert(preprint.get_group('admin') in user.groups.all()) diff --git a/api/base/serializers.py b/api/base/serializers.py index de74444fe1c..72386985968 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -1674,7 +1674,12 @@ def update(self, instance, validated_data): for pointer in remove: collection.rm_pointer(pointer, auth) for node in add: - collection.add_pointer(node, auth) + try: + collection.add_pointer(node, auth) + except ValueError as e: + raise api_exceptions.InvalidModelValueError( + detail=str(e), + ) return self.make_instance_obj(collection) @@ -1689,8 +1694,12 @@ def create(self, validated_data): raise api_exceptions.RelationshipPostMakesNoChanges for node in add: - collection.add_pointer(node, auth) - + try: + collection.add_pointer(node, auth) + except ValueError as e: + raise api_exceptions.InvalidModelValueError( + detail=str(e), + ) return self.make_instance_obj(collection) @@ -1747,7 +1756,12 @@ def update(self, instance, validated_data): for pointer in remove: collection.rm_pointer(pointer, auth) for node in add: - collection.add_pointer(node, auth) + try: + collection.add_pointer(node, auth) + except ValueError as e: + raise api_exceptions.InvalidModelValueError( + detail=str(e), + ) return self.make_instance_obj(collection) @@ -1762,7 +1776,12 @@ def create(self, validated_data): raise api_exceptions.RelationshipPostMakesNoChanges for node in add: - collection.add_pointer(node, auth) + try: + collection.add_pointer(node, auth) + except ValueError as e: + raise api_exceptions.InvalidModelValueError( + detail=str(e), + ) return self.make_instance_obj(collection) diff --git a/api/nodes/serializers.py b/api/nodes/serializers.py index d928cb71d79..fb585f31263 100644 --- a/api/nodes/serializers.py +++ b/api/nodes/serializers.py @@ -141,8 +141,8 @@ def to_internal_value(self, data): class NodeLicenseSerializer(BaseAPISerializer): - copyright_holders = ser.ListField(allow_empty=True) - year = ser.CharField(allow_blank=True) + copyright_holders = ser.ListField(allow_empty=True, required=False) + year = ser.CharField(allow_blank=True, required=False) class Meta: type_ = 'node_licenses' @@ -206,8 +206,12 @@ class Meta: type_ = 'styled-citations' def get_license_details(node, validated_data): - license = node.license if isinstance(node, Preprint) else node.node_license - + if node: + license = node.license if isinstance(node, Preprint) else node.node_license + else: + license = None + if ('license_type' not in validated_data and not (license and license.node_license.license_id)): + raise exceptions.ValidationError(detail='License ID must be provided for a Node License.') license_id = license.node_license.license_id if license else None license_year = license.year if license else None license_holders = license.copyright_holders if license else [] @@ -747,10 +751,18 @@ def create(self, validated_data): tag_instances = [] affiliated_institutions = None region_id = None + license_details = None if 'affiliated_institutions' in validated_data: affiliated_institutions = validated_data.pop('affiliated_institutions') if 'region_id' in validated_data: region_id = validated_data.pop('region_id') + if 'license_type' in validated_data or 'license' in validated_data: + try: + license_details = get_license_details(None, validated_data) + except ValidationError as e: + raise InvalidModelValueError(detail=str(e.messages[0])) + validated_data.pop('license', None) + validated_data.pop('license_type', None) if 'tags' in validated_data: tags = validated_data.pop('tags') for tag in tags: @@ -806,6 +818,20 @@ def create(self, validated_data): node.subjects.add(parent.subjects.all()) node.save() + if license_details: + try: + node.set_node_license( + { + 'id': license_details.get('id') if license_details.get('id') else 'NONE', + 'year': license_details.get('year'), + 'copyrightHolders': license_details.get('copyrightHolders') or license_details.get('copyright_holders', []), + }, + auth=get_user_auth(request), + save=True, + ) + except ValidationError as e: + raise InvalidModelValueError(detail=str(e.message)) + if not region_id: region_id = self.context.get('region_id') if region_id: @@ -1314,10 +1340,10 @@ def create(self, validated_data): try: pointer = node.add_pointer(pointer_node, auth, save=True) return pointer - except ValueError: + except ValueError as e: raise InvalidModelValueError( source={'pointer': '/data/relationships/node_links/data/id'}, - detail='Target Node \'{}\' already pointed to by \'{}\'.'.format(target_node_id, node._id), + detail=str(e), ) def update(self, instance, validated_data): diff --git a/api/wb/views.py b/api/wb/views.py index 7b1300c2bcc..f8bb0387ea7 100644 --- a/api/wb/views.py +++ b/api/wb/views.py @@ -10,6 +10,9 @@ WaterbutlerMetadataSerializer, ) +from api.caching.tasks import update_storage_usage + + class FileMetadataView(APIView): """ Mixin with common code for WB move/copy hooks @@ -77,7 +80,15 @@ def post(self, request, *args, **kwargs): return response def perform_file_action(self, source, destination, name): - return source.move_under(destination, name) + dest_target = destination.target + source_target = source.target + ret = source.move_under(destination, name) + + if dest_target != source_target: + update_storage_usage(source.target) + update_storage_usage(destination.target) + + return ret class CopyFileMetadataView(FileMetadataView): @@ -89,4 +100,6 @@ class CopyFileMetadataView(FileMetadataView): view_name = 'metadata-copy' def perform_file_action(self, source, destination, name): - return source.copy_under(destination, name) + ret = source.copy_under(destination, name) + update_storage_usage(destination.target) + return ret diff --git a/api_tests/nodes/views/test_node_detail.py b/api_tests/nodes/views/test_node_detail.py index dc5e128ba18..6c3f286ba67 100644 --- a/api_tests/nodes/views/test_node_detail.py +++ b/api_tests/nodes/views/test_node_detail.py @@ -2388,6 +2388,21 @@ def test_update_node_license_without_required_year_in_payload( assert res.status_code == 400 assert res.json['errors'][0]['detail'] == 'year must be specified for this license' + def test_update_node_license_without_license_id( + self, node, make_payload, make_request, url_node, user_admin_contrib): + data = make_payload( + node_id=node._id, + license_year='2015', + copyright_holders=['Ben, Jerry'] + ) + + res = make_request( + url_node, data, + auth=user_admin_contrib.auth, + expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'License ID must be provided for a Node License.' + def test_update_node_license_without_required_copyright_holders_in_payload_( self, user_admin_contrib, node, make_payload, make_request, license_no, url_node): data = make_payload( diff --git a/api_tests/nodes/views/test_node_linked_nodes.py b/api_tests/nodes/views/test_node_linked_nodes.py index 7f0aca609a3..18f1a7208f4 100644 --- a/api_tests/nodes/views/test_node_linked_nodes.py +++ b/api_tests/nodes/views/test_node_linked_nodes.py @@ -6,6 +6,7 @@ NodeFactory, OSFGroupFactory, AuthUserFactory, + NodeRelationFactory, ) from osf.utils.permissions import WRITE, READ from website.project.signals import contributor_removed @@ -359,7 +360,7 @@ def test_delete_invalid_payload( def test_node_errors( self, app, user, node_private, node_contrib, node_public, node_other, make_payload, - url_private, url_public): + url_private, url_public, node_linking_private): # test_node_doesnt_exist res = app.post_json_api( @@ -453,6 +454,8 @@ def test_node_errors( auth=user.auth, expect_errors=True ) + # test_put_child_node + assert res.status_code == 403 # test_delete_public_nodes_relationships_logged_out @@ -471,6 +474,106 @@ def test_node_errors( assert res.status_code == 403 + # test_node_child_cannot_be_linked_on_create + node_child = NodeFactory(creator=user) + node_parent = NodeFactory(creator=user) + node_parent_child = NodeRelationFactory(child=node_child, parent=node_parent) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_parent_child.parent._id + ) + res = app.post_json_api( + url, { + 'data': [{ + 'type': 'linked_nodes', + 'id': node_parent_child.child._id + }] + }, + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + + # test_linking_node_to_itself _on_create + node_self = NodeFactory(creator=user) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_self._id + ) + res = app.post_json_api( + url_private, make_payload([node_linking_private._id]), + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + + # test_linking_child_node_to_parent_on_create + node_child = NodeFactory(creator=user) + node_parent = NodeFactory(creator=user) + node_parent_child = NodeRelationFactory(child=node_child, parent=node_parent) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_parent_child.child._id + ) + res = app.post_json_api( + url, { + 'data': [{ + 'type': 'linked_nodes', + 'id': node_parent_child.parent._id + }] + }, + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + + # test_node_child_cannot_be_linked_on_update + node_child = NodeFactory(creator=user) + node_parent = NodeFactory(creator=user) + node_parent_child = NodeRelationFactory(child=node_child, parent=node_parent) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_parent_child.parent._id + ) + res = app.put_json_api( + url, { + 'data': [{ + 'type': 'linked_nodes', + 'id': node_parent_child.child._id + }] + }, + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + + # test_linking_child_node_to_parent_on_update + node_child = NodeFactory(creator=user) + node_parent = NodeFactory(creator=user) + node_parent_child = NodeRelationFactory(child=node_child, parent=node_parent) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_parent_child.child._id + ) + res = app.put_json_api( + url, { + 'data': [{ + 'type': 'linked_nodes', + 'id': node_parent_child.parent._id + }] + }, + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + + # test_linking_node_to_itself _on_update + node_self = NodeFactory(creator=user) + url = '/{}nodes/{}/relationships/linked_nodes/'.format( + API_BASE, node_self._id + ) + res = app.put_json_api( + url_private, make_payload([node_linking_private._id]), + auth=user.auth, expect_errors=True + ) + + assert res.status_code == 400 + def test_node_links_and_relationship_represent_same_nodes( self, app, user, node_admin, node_contrib, node_linking_private, url_private): node_linking_private.add_pointer(node_admin, auth=Auth(user)) diff --git a/api_tests/nodes/views/test_node_linked_registrations.py b/api_tests/nodes/views/test_node_linked_registrations.py index 7c10f39e93c..43cea6dbcd0 100644 --- a/api_tests/nodes/views/test_node_linked_registrations.py +++ b/api_tests/nodes/views/test_node_linked_registrations.py @@ -7,6 +7,7 @@ NodeFactory, OSFGroupFactory, RegistrationFactory, + NodeRelationFactory, ) from osf.utils.permissions import READ from rest_framework import exceptions @@ -276,8 +277,8 @@ def test_rw_contributor_can_create_linked_registrations_relationship( assert registration._id in linked_registrations def test_cannot_create_linked_registrations_relationship( - self, make_request, user_admin_contrib, user_read_contrib, - user_non_contrib, node_private): + self, app, make_request, user_admin_contrib, user_read_contrib, + user_non_contrib, node_private, make_payload): # test_read_contributor_cannot_create_linked_registrations_relationship registration = RegistrationFactory(is_public=True) @@ -345,6 +346,26 @@ def test_cannot_create_linked_registrations_relationship( assert res.status_code == 403 assert res.json['errors'][0]['detail'] == exceptions.PermissionDenied.default_detail + # test_cannot_create_relationship_with_child_registration + child_reg = RegistrationFactory(creator=user_admin_contrib) + NodeRelationFactory(child=child_reg, parent=node_private) + url = '/{}nodes/{}/relationships/linked_registrations/'.format( + API_BASE, node_private._id) + data = make_payload(registration_id=child_reg._id) + res = app.post_json_api(url, data, auth=user_admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Target Node \'{}\' is already a child of \'{}\'.'.format(child_reg._id, node_private._id) + + # test_cannot_create_link_registration_to_itself + res = make_request( + node_id=node_private._id, + reg_id=node_private._id, + auth=user_admin_contrib.auth, + expect_errors=True + ) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Cannot link node \'{}\' to itself.'.format(node_private._id) + def test_create_linked_registrations_relationship_registration_already_in_linked_registrations_returns_no_content( self, make_request, registration, node_private, user_admin_contrib): res = make_request( @@ -484,7 +505,7 @@ def test_empty_payload_removes_existing_linked_registrations( assert registration._id not in linked_registrations def test_cannot_update_linked_registrations_relationship( - self, make_request, user_read_contrib, user_non_contrib, node_private): + self, app, make_request, make_payload, user_read_contrib, user_non_contrib, node_private, user_admin_contrib): # test_read_contributor_cannot_update_linked_registrations_relationship registration = RegistrationFactory(is_public=True) @@ -518,6 +539,25 @@ def test_cannot_update_linked_registrations_relationship( assert res.status_code == 401 assert res.json['errors'][0]['detail'] == exceptions.NotAuthenticated.default_detail + # test_cannot_update_relationship_with_child_registration + child_reg = RegistrationFactory(creator=user_admin_contrib) + NodeRelationFactory(child=child_reg, parent=node_private) + url = '/{}nodes/{}/relationships/linked_registrations/'.format( + API_BASE, node_private._id) + data = make_payload(registration_id=child_reg._id) + res = app.put_json_api(url, data, auth=user_admin_contrib.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Target Node \'{}\' is already a child of \'{}\'.'.format(child_reg._id, node_private._id) + + # test_cannot_update_link_registration_to_itself + res = make_request( + node_id=node_private._id, + reg_id=node_private._id, + auth=user_admin_contrib.auth, + expect_errors=True + ) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'Cannot link node \'{}\' to itself.'.format(node_private._id) @pytest.mark.django_db class TestNodeLinkedRegistrationsRelationshipDelete( diff --git a/api_tests/nodes/views/test_node_links_list.py b/api_tests/nodes/views/test_node_links_list.py index 90ab6ba4560..f5d6ebad68d 100644 --- a/api_tests/nodes/views/test_node_links_list.py +++ b/api_tests/nodes/views/test_node_links_list.py @@ -509,17 +509,12 @@ def test_create_fake_node_pointing_to_contributing_node( def test_create_node_pointer_to_itself( self, app, user, public_project, public_url, make_payload): - with assert_latest_log(NodeLog.POINTER_CREATED, public_project): - point_to_itself_payload = make_payload(id=public_project._id) - res = app.post_json_api( - public_url, - point_to_itself_payload, - auth=user.auth) - res_json = res.json['data'] - assert res.status_code == 201 - assert res.content_type == 'application/vnd.api+json' - embedded = res_json['embeds']['target_node']['data']['id'] - assert embedded == public_project._id + point_to_itself_payload = make_payload(id=public_project._id) + res = app.post_json_api( + public_url, + point_to_itself_payload, + auth=user.auth, expect_errors=True) + assert res.status_code == 400 def test_create_node_pointer_errors( self, app, user, user_two, public_project, @@ -984,29 +979,24 @@ def test_bulk_creates_node_pointers_contributing_node_to_non_contributing_node( def test_bulk_creates_node_pointer_to_itself( self, app, user, public_project, public_url): - with assert_latest_log(NodeLog.POINTER_CREATED, public_project): - point_to_itself_payload = { - 'data': [{ - 'type': 'node_links', - 'relationships': { - 'nodes': { - 'data': { - 'type': 'nodes', - 'id': public_project._id - } + point_to_itself_payload = { + 'data': [{ + 'type': 'node_links', + 'relationships': { + 'nodes': { + 'data': { + 'type': 'nodes', + 'id': public_project._id } } - }] - } + } + }] + } - res = app.post_json_api( - public_url, point_to_itself_payload, - auth=user.auth, bulk=True) - assert res.status_code == 201 - assert res.content_type == 'application/vnd.api+json' - res_json = res.json['data'] - embedded = res_json[0]['embeds']['target_node']['data']['id'] - assert embedded == public_project._id + res = app.post_json_api( + public_url, point_to_itself_payload, + auth=user.auth, bulk=True, expect_errors=True) + assert res.status_code == 400 def test_bulk_creates_node_pointer_already_connected( self, app, user, public_project, diff --git a/api_tests/nodes/views/test_node_list.py b/api_tests/nodes/views/test_node_list.py index cda298f13e6..cbd3de4b8c4 100644 --- a/api_tests/nodes/views/test_node_list.py +++ b/api_tests/nodes/views/test_node_list.py @@ -7,6 +7,7 @@ from api_tests.subjects.mixins import SubjectsFilterMixin from framework.auth.core import Auth from osf.models import AbstractNode, Node, NodeLog +from osf.models.licenses import NodeLicense from osf.utils.sanitize import strip_html from osf.utils import permissions from osf_tests.factories import ( @@ -1957,6 +1958,140 @@ def test_create_project_errors( assert res.status_code == 400 assert res.json['errors'][0]['detail'] == 'Title cannot exceed 512 characters.' +@pytest.mark.django_db +class TestNodeLicenseOnCreate: + + @pytest.fixture() + def user(self): + return AuthUserFactory() + + @pytest.fixture() + def url(self): + return '/{}nodes/'.format(API_BASE) + + @pytest.fixture() + def license_name(self): + return 'MIT License' + + @pytest.fixture() + def node_license(self, license_name): + return NodeLicense.objects.filter(name=license_name).first() + + @pytest.fixture() + def make_payload(self): + def payload( + license_id=None, license_year=None, copyright_holders=None): + attributes = {} + + if license_year and copyright_holders: + attributes = { + 'title': 'new title', + 'category': 'project', + 'tags': ['foo', 'bar'], + 'node_license': { + 'copyright_holders': copyright_holders, + 'year': license_year, + } + } + elif license_year: + attributes = { + 'title': 'new title', + 'category': 'project', + 'tags': ['foo', 'bar'], + 'node_license': { + 'year': license_year, + } + } + elif copyright_holders: + attributes = { + 'title': 'new title', + 'category': 'project', + 'tags': ['foo', 'bar'], + 'node_license': { + 'copyright_holders': copyright_holders + } + } + + return { + 'data': { + 'type': 'nodes', + 'attributes': attributes, + 'relationships': { + 'license': { + 'data': { + 'type': 'licenses', + 'id': license_id + } + } + } + } + } if license_id else { + 'data': { + 'type': 'nodes', + 'attributes': attributes + } + } + return payload + + def test_node_license_on_create( + self, app, user, url, node_license, make_payload): + data = make_payload( + copyright_holders=['Haagen', 'Dazs'], + license_year='2200', + license_id=node_license._id + ) + res = app.post_json_api( + url, data, auth=user.auth) + assert res.json['data']['attributes']['node_license']['year'] == '2200' + assert res.json['data']['attributes']['node_license']['copyright_holders'] == ['Haagen', 'Dazs'] + assert res.json['data']['relationships']['license']['data']['id'] == node_license._id + + def test_create_node_license_errors( + self, app, url, user, node_license, make_payload): + + # test_creating_a_node_license_without_a_license_id + data = make_payload( + license_year='2200', + copyright_holders=['Ben', 'Jerry'] + ) + res = app.post_json_api( + url, data, auth=user.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'License ID must be provided for a Node License.' + + # test_creating_a_node_license_without_a_copyright_holder + data = make_payload( + license_year='2200', + license_id=node_license._id + ) + res = app.post_json_api( + url, data, + auth=user.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'copyrightHolders must be specified for this license' + + # test_creating_a_node_license_without_a_year + data = make_payload( + copyright_holders=['Baskin', 'Robbins'], + license_id=node_license._id + ) + res = app.post_json_api( + url, data, + auth=user.auth, expect_errors=True) + assert res.status_code == 400 + assert res.json['errors'][0]['detail'] == 'year must be specified for this license' + + # test_creating_a_node_license_with_an_invalid_ID + data = make_payload( + license_year='2200', + license_id='invalid id', + copyright_holders=['Ben', 'Jerry'] + ) + res = app.post_json_api( + url, data, + auth=user.auth, expect_errors=True) + assert res.status_code == 404 + assert res.json['errors'][0]['detail'] == 'Unable to find specified license.' @pytest.mark.django_db class TestNodeBulkCreate: diff --git a/api_tests/wb/views/test_wb_hooks.py b/api_tests/wb/views/test_wb_hooks.py index b1933b2859a..89bf3a18f10 100644 --- a/api_tests/wb/views/test_wb_hooks.py +++ b/api_tests/wb/views/test_wb_hooks.py @@ -5,6 +5,8 @@ from addons.osfstorage.models import OsfStorageFolder from framework.auth import signing +from api.caching.tasks import update_storage_usage_cache + from osf_tests.factories import ( AuthUserFactory, ProjectFactory, @@ -326,6 +328,44 @@ def test_node_in_params_does_not_exist(self, app, file, root_node, user, folder) res = app.post_json(move_url, signed_payload, expect_errors=True) assert res.status_code == 404 + def test_storage_usage_move_within_node(self, app, node, signed_payload, move_url): + """ + Checking moves within a node, since the net value hasn't changed the cache will remain expired at None. + """ + assert node.storage_usage is None + + res = app.post_json(move_url, signed_payload) + + assert res.status_code == 200 + assert node.storage_usage is None # this is intentional, the cache shouldn't be touched + + def test_storage_usage_move_between_nodes(self, app, node, node_two, file, root_node, user, node_two_root_node, move_url): + """ + Checking storage usage when moving files outside a node mean both need to be recalculated, as both values have + changed. + """ + + assert node.storage_usage is None # the cache starts expired, but there is 1337 bytes in there + assert node_two.storage_usage is None # zero bytes here + + signed_payload = sign_payload( + { + 'source': file._id, + 'target': node._id, + 'user': user._id, + 'destination': { + 'parent': node_two_root_node._id, + 'target': node_two._id, + 'name': 'test_file', + } + } + ) + res = app.post_json(move_url, signed_payload) + assert res.status_code == 200 + + assert node.storage_usage is None + assert node_two.storage_usage == 1337 + @pytest.mark.django_db class TestMovePreprint(): @@ -802,6 +842,50 @@ def test_invalid_payload(self, app, copy_url): assert res.status_code == 400 assert res.json['errors'][0]['detail'] == 'Invalid Payload' + def test_storage_usage_copy_within_node(self, app, node, file, signed_payload, copy_url): + """ + Checking copys within a node, since the net size will double the storage usage should be the file size * 2 + """ + assert node.storage_usage is None + + res = app.post_json(copy_url, signed_payload) + + assert res.status_code == 201 + assert node.storage_usage == file.versions.last().metadata['size'] * 2 + + def test_storage_usage_copy_between_nodes(self, app, node, node_two, file, user, node_two_root_node, copy_url): + """ + Checking storage usage when copying files to outside a node means only the destination should be recalculated. + """ + + assert node.storage_usage is None # The node cache starts expired, but there is 1337 bytes in there + assert node_two.storage_usage is None # There are zero bytes in node_two + + signed_payload = sign_payload( + { + 'source': file._id, + 'target': node._id, + 'user': user._id, + 'destination': { + 'parent': node_two_root_node._id, + 'target': node_two._id, + 'name': 'test_file', + } + } + ) + res = app.post_json(copy_url, signed_payload) + assert res.status_code == 201 + + # The node cache is None because it's value should be unchanged -- + assert node.storage_usage is None + + # But there's really 1337 bytes in the node + update_storage_usage_cache(node._id) + assert node.storage_usage == 1337 + + # And we have exactly 1337 bytes copied in node_two + assert node_two.storage_usage == 1337 + @pytest.mark.django_db @pytest.mark.enable_quickfiles_creation diff --git a/framework/auth/views.py b/framework/auth/views.py index a317cdd51a8..9e1bfeb4e24 100644 --- a/framework/auth/views.py +++ b/framework/auth/views.py @@ -67,9 +67,13 @@ def reset_password_get(auth, uid=None, token=None): user_obj.verification_key_v2 = generate_verification_key(verification_type='password') user_obj.save() + #override routes.py login_url to redirect to dashboard + service_url = web_url_for('dashboard', _absolute=True) + return { 'uid': user_obj._id, 'token': user_obj.verification_key_v2['token'], + 'login_url': service_url, } @@ -138,7 +142,11 @@ def forgot_password_get(auth): if auth.logged_in: return auth_logout(redirect_url=request.url) - return {} + #overriding the routes.py sign in url to redirect to the dashboard after login + context = {} + context['login_url'] = web_url_for('dashboard', _absolute=True) + + return context def forgot_password_post(): diff --git a/osf/admin.py b/osf/admin.py index 39c614ae059..601bcb3bb22 100644 --- a/osf/admin.py +++ b/osf/admin.py @@ -20,7 +20,7 @@ def formfield_for_manytomany(self, db_field, request, **kwargs): Restricts preprint/node/osfgroup django groups from showing up in the user's groups list in the admin app """ if db_field.name == 'groups': - kwargs['queryset'] = Group.objects.exclude(Q(name__startswith='preprint_') | Q(name__startswith='node_') | Q(name__startswith='osfgroup_')) + kwargs['queryset'] = Group.objects.exclude(Q(name__startswith='preprint_') | Q(name__startswith='node_') | Q(name__startswith='osfgroup_') | Q(name__startswith='collections_')) return super(OSFUserAdmin, self).formfield_for_manytomany(db_field, request, **kwargs) def save_related(self, request, form, formsets, change): @@ -28,7 +28,7 @@ def save_related(self, request, form, formsets, change): Since m2m fields overridden with new form data in admin app, preprint groups/node/osfgroup groups (which are now excluded from being selections) are removed. Manually re-adds preprint/node groups after adding new groups in form. """ - groups_to_preserve = list(form.instance.groups.filter(Q(name__startswith='preprint_') | Q(name__startswith='node_') | Q(name__startswith='osfgroup_'))) + groups_to_preserve = list(form.instance.groups.filter(Q(name__startswith='preprint_') | Q(name__startswith='node_') | Q(name__startswith='osfgroup_') | Q(name__startswith='collections_'))) super(OSFUserAdmin, self).save_related(request, form, formsets, change) if 'groups' in form.cleaned_data: for group in groups_to_preserve: diff --git a/osf/models/mixins.py b/osf/models/mixins.py index 9ab02ba64de..8fd6a9b30ac 100644 --- a/osf/models/mixins.py +++ b/osf/models/mixins.py @@ -337,13 +337,11 @@ def add_node_link(self, node, auth, save=True): :param bool save: Save changes :return: Created pointer """ - # Fail if node already in nodes / pointers. Note: cast node and node - # to primary keys to test for conflicts with both nodes and pointers - # contained in `self.nodes`. - if NodeRelation.objects.filter(parent=self, child=node, is_node_link=True).exists(): - raise ValueError( - 'Link to node {0} already exists'.format(node._id) - ) + try: + self.check_node_link(child_node=node, parent_node=self) + self.check_node_link(child_node=self, parent_node=node) + except ValueError as e: + raise ValueError(e.message) if self.is_registration: raise self.state_error('Cannot add a node link to a registration') @@ -381,6 +379,21 @@ def add_node_link(self, node, auth, save=True): add_pointer = add_node_link # For v1 compat + def check_node_link(self, child_node, parent_node): + if child_node._id == parent_node._id: + raise ValueError( + 'Cannot link node \'{}\' to itself.'.format(child_node._id) + ) + existant_relation = NodeRelation.objects.filter(parent=parent_node, child=child_node).first() + if existant_relation and existant_relation.is_node_link: + raise ValueError( + 'Target Node \'{}\' already pointed to by \'{}\'.'.format(child_node._id, parent_node._id) + ) + elif existant_relation and not existant_relation.is_node_link: + raise ValueError( + 'Target Node \'{}\' is already a child of \'{}\'.'.format(child_node._id, parent_node._id) + ) + def rm_node_link(self, node_relation, auth): """Remove a pointer. diff --git a/osf/models/user.py b/osf/models/user.py index 8cb415846de..3061ddf7e24 100644 --- a/osf/models/user.py +++ b/osf/models/user.py @@ -10,6 +10,7 @@ from flask import Request as FlaskRequest from framework import analytics from guardian.shortcuts import get_perms +from past.builtins import basestring # OSF imports import itsdangerous @@ -453,13 +454,31 @@ def unconfirmed_emails(self): @property def social_links(self): + """ + Returns a dictionary of formatted social links for a user. + + Social account values which are stored as account names are + formatted into appropriate social links. The 'type' of each + respective social field value is dictated by self.SOCIAL_FIELDS. + + I.e. If a string is expected for a specific social field that + permits multiple accounts, a single account url will be provided for + the social field to ensure adherence with self.SOCIAL_FIELDS. + """ social_user_fields = {} for key, val in self.social.items(): if val and key in self.SOCIAL_FIELDS: - if not isinstance(val, basestring): - social_user_fields[key] = val + if isinstance(self.SOCIAL_FIELDS[key], basestring): + if isinstance(val, basestring): + social_user_fields[key] = self.SOCIAL_FIELDS[key].format(val) + else: + # Only provide the first url for services where multiple accounts are allowed + social_user_fields[key] = self.SOCIAL_FIELDS[key].format(val[0]) else: - social_user_fields[key] = self.SOCIAL_FIELDS[key].format(val) + if isinstance(val, basestring): + social_user_fields[key] = [val] + else: + social_user_fields[key] = val return social_user_fields @property diff --git a/osf_tests/test_node.py b/osf_tests/test_node.py index 5e24ef050f2..8b7bea86afa 100644 --- a/osf_tests/test_node.py +++ b/osf_tests/test_node.py @@ -286,8 +286,6 @@ def test_get_children_with_links(self): NodeFactory(parent=grandchild3) greatgrandchild_1 = NodeFactory(parent=grandchild_1) - child.add_node_link(root, auth=Auth(root.creator)) - child.add_node_link(greatgrandchild_1, auth=Auth(greatgrandchild_1.creator)) greatgrandchild_1.add_node_link(child, auth=Auth(child.creator)) assert 20 == len(Node.objects.get_children(root)) @@ -2733,7 +2731,6 @@ def test_get_descendants_recursive_cyclic(self, user, root, auth): point1 = ProjectFactory(creator=user, parent=root) point2 = ProjectFactory(creator=user, parent=root) point1.add_pointer(point2, auth=auth) - point2.add_pointer(point1, auth=auth) descendants = list(point1.get_descendants_recursive()) assert len(descendants) == 1 diff --git a/osf_tests/test_user.py b/osf_tests/test_user.py index c460e29d410..7f44832f85e 100644 --- a/osf_tests/test_user.py +++ b/osf_tests/test_user.py @@ -2110,21 +2110,23 @@ def test_profile_website_unchanged(self): def test_various_social_handles(self): self.user.social = { 'profileWebsites': ['http://cos.io/'], - 'twitter': 'OSFramework', - 'github': 'CenterForOpenScience' + 'twitter': ['OSFramework'], + 'github': ['CenterForOpenScience'], + 'scholar': 'ztt_j28AAAAJ' } self.user.save() assert self.user.social_links == { 'profileWebsites': ['http://cos.io/'], 'twitter': 'http://twitter.com/OSFramework', - 'github': 'http://github.com/CenterForOpenScience' + 'github': 'http://github.com/CenterForOpenScience', + 'scholar': 'http://scholar.google.com/citations?user=ztt_j28AAAAJ' } def test_multiple_profile_websites(self): self.user.social = { 'profileWebsites': ['http://cos.io/', 'http://thebuckstopshere.com', 'http://dinosaurs.com'], - 'twitter': 'OSFramework', - 'github': 'CenterForOpenScience' + 'twitter': ['OSFramework'], + 'github': ['CenterForOpenScience'] } self.user.save() assert self.user.social_links == { diff --git a/package.json b/package.json index 70eab34d83b..c5ea698507d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "OSF", - "version": "19.23.0", + "version": "19.24.0", "description": "Facilitating Open Science", "repository": "https://github.com/CenterForOpenScience/osf.io", "author": "Center for Open Science", diff --git a/scripts/retract_registrations.py b/scripts/retract_registrations.py index 534f2287258..592788e7931 100644 --- a/scripts/retract_registrations.py +++ b/scripts/retract_registrations.py @@ -49,7 +49,7 @@ def main(dry_run=True): 'registration': parent_registration._id, 'retraction_id': parent_registration.retraction._id, }, - auth=Auth(parent_registration.retraction.initiated_by), + auth=None, ) retraction.save() parent_registration.update_search() diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 5d9b50a0c61..a237f23d728 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -945,7 +945,6 @@ def test_format_data_user_subscriptions_includes_private_parent_if_configured_ch def test_format_data_user_subscriptions_if_children_points_to_parent(self): private_project = factories.ProjectFactory(creator=self.user) node = factories.NodeFactory(parent=private_project, creator=self.user) - node.add_pointer(private_project, Auth(self.user)) node.save() node_comments_subscription = factories.NotificationSubscriptionFactory( _id=node._id + '_' + 'comments', diff --git a/tests/test_views.py b/tests/test_views.py index 591b81dec9d..db884bcd5c4 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1049,7 +1049,6 @@ def test_get_node_with_children(self): def test_get_node_with_child_linked_to_parent(self): project = ProjectFactory(creator=self.user) child1 = NodeFactory(parent=project, creator=self.user) - child1.add_pointer(project, Auth(self.user)) child1.save() url = project.api_url_for('get_node_tree') res = self.app.get(url, auth=self.user.auth) @@ -3144,7 +3143,6 @@ def test_get_pointed_private(self): def test_can_template_project_linked_to_each_other(self): project2 = ProjectFactory(creator=self.user) self.project.add_pointer(project2, auth=Auth(user=self.user)) - project2.add_pointer(self.project, auth=Auth(user=self.user)) template = self.project.use_as_template(auth=Auth(user=self.user)) assert_true(template) diff --git a/website/static/js/anonymousLogActionsList.json b/website/static/js/anonymousLogActionsList.json index 28084915411..e5ec33b6b3f 100644 --- a/website/static/js/anonymousLogActionsList.json +++ b/website/static/js/anonymousLogActionsList.json @@ -61,13 +61,14 @@ "embargo_completed_no_user" : "Embargo for a project completed", "embargo_terminated": "Embargo for a project ended", "retraction_initiated" : "A user initiated a withdrawal of a registration of a project", - "retraction_initiated_no_user" : "A withdrawal of registration of a project is proposed", - "retraction_approved" : "A user approved a withdrawal of registration of a project", - "retraction_cancelled" : "A user cancelled a withdrawal of registration of a project", + "retraction_initiated_no_user" : "A withdrawal of a registration of a project was proposed", + "retraction_approved" : "A user approved a withdrawal of a registration of a project", + "retraction_approved_no_user": "A withdrawal of a registration of ${node} was approved", + "retraction_cancelled" : "A user cancelled a withdrawal of a registration of a project", "registration_initiated" : "A user initiated a registration of a project", - "registration_approved" : "A user approved registration of a project", - "registration_approved_no_user" : "Registration of a project approved", - "registration_cancelled" : "A user cancelled registration of a project", + "registration_approved" : "A user approved a registration of a project", + "registration_approved_no_user" : "Registration of a project was approved", + "registration_cancelled" : "A user cancelled a registration of a project", "node_created" : "A user created a project", "node_forked" : "A user created fork from a project", "node_removed" : "A user removed a project", diff --git a/website/static/js/logActionsList.json b/website/static/js/logActionsList.json index f35835ffebc..ae7673ec145 100644 --- a/website/static/js/logActionsList.json +++ b/website/static/js/logActionsList.json @@ -60,14 +60,15 @@ "embargo_completed": "${user} completed embargo of ${node}", "embargo_completed_no_user": "Embargo for ${node} completed", "embargo_terminated": "Embargo for ${node} ended", - "retraction_initiated": "${user} initiated withdrawal of registration of ${node}", - "retraction_initiated_no_user": "A withdrawal of registration of ${node} is proposed", - "retraction_approved": "${user} approved withdrawal of registration of ${node}", - "retraction_cancelled": "${user} cancelled withdrawal of registration of ${node}", - "registration_initiated": "${user} initiated registration of ${node}", - "registration_approved": "${user} approved registration of ${node}", - "registration_approved_no_user": "Registration of ${node} approved", - "registration_cancelled": "${user} cancelled registration of ${node}", + "retraction_initiated": "${user} initiated withdrawal of a registration of ${node}", + "retraction_initiated_no_user": "A withdrawal of a registration of ${node} was proposed", + "retraction_approved": "${user} approved a withdrawal of a registration of ${node}", + "retraction_approved_no_user": "A withdrawal of a registration of ${node} was approved", + "retraction_cancelled": "${user} cancelled withdrawal of a registration of ${node}", + "registration_initiated": "${user} initiated a registration of ${node}", + "registration_approved": "${user} approved a registration of ${node}", + "registration_approved_no_user": "Registration of ${node} was approved", + "registration_cancelled": "${user} cancelled a registration of ${node}", "node_created": "${user} created ${node}", "node_forked": "${user} created fork from ${forked_from}", "node_removed": "${user} removed ${node}", diff --git a/website/static/js/logTextParser.js b/website/static/js/logTextParser.js index 489c17e0a5d..2c296593ffa 100644 --- a/website/static/js/logTextParser.js +++ b/website/static/js/logTextParser.js @@ -662,7 +662,7 @@ var LogPieces = { googledrive_folder: { view: function(ctrl, logObject){ - var folder = logObject.attributes.params.folder; + var folder = logObject.attributes.params.folder_name; if(paramIsReturned(folder, logObject)){ return m('span', folder === '/' ? '(Full Google Drive)' : folder); } diff --git a/website/templates/emails/pending_embargo_admin.html.mako b/website/templates/emails/pending_embargo_admin.html.mako index b452d3fec51..9be83fd2cfd 100644 --- a/website/templates/emails/pending_embargo_admin.html.mako +++ b/website/templates/emails/pending_embargo_admin.html.mako @@ -11,16 +11,16 @@ ${initiated_by} initiated an embargoed registration of ${project_name}. The proposed registration can be viewed here: ${registration_link}.
% endif
- If approved, a registration will be created for the project and it will remain private until it is withdrawn, manually
- made public, or the embargo end date has passed on ${embargo_end_date.date()}.
+ If approved, a registration will be created for the project, and it will remain private until it is withdrawn, + it is manually made public, or the embargo end date is passed on ${embargo_end_date.date()}.

To approve this embargo, click the following link: ${approval_link}.

To cancel this embargo, click the following link: ${disapproval_link}.

- Note: Clicking the disapproval link will immediately cancel the pending embargo and the
- registration will remain in draft state. If you neither approve nor disapprove the embargo
- within ${approval_time_span} hours from midnight tonight (EDT) the registration will remain
+ Note: Clicking the disapproval link will immediately cancel the pending embargo and the + registration will remain in draft state. If you neither approve nor disapprove the embargo + within ${approval_time_span} hours from midnight tonight (EDT) the registration will remain private and enter into an embargoed state.

Sincerely yours,
diff --git a/website/templates/emails/pending_embargo_non_admin.html.mako b/website/templates/emails/pending_embargo_non_admin.html.mako index 3d5b32f85f3..bbd302ba4f1 100644 --- a/website/templates/emails/pending_embargo_non_admin.html.mako +++ b/website/templates/emails/pending_embargo_non_admin.html.mako @@ -7,8 +7,8 @@
We just wanted to let you know that ${initiated_by} has initiated an embargoed registration for the following pending registration: ${registration_link}.

- If approved, a registration will be created for the project, viewable here: ${registration_link}, and it will remain
- private until it is withdrawn, manually made public, or the embargo end date has passed on ${embargo_end_date.date()}.
+ If approved, a registration will be created for the project, viewable here: ${registration_link}, and it will remain + private until it is withdrawn, it is manually made public, or the embargo end date is passed on ${embargo_end_date.date()}.

Sincerely yours,

diff --git a/website/templates/public/resetpassword.mako b/website/templates/public/resetpassword.mako index f417ea3495a..39d5edc96bf 100644 --- a/website/templates/public/resetpassword.mako +++ b/website/templates/public/resetpassword.mako @@ -96,6 +96,12 @@ +<%def name="javascript()"> + + + <%def name="javascript_bottom()">