diff --git a/core/pins/migrations/0005_pins_to_follows.py b/core/pins/migrations/0005_pins_to_follows.py new file mode 100644 index 000000000..67924f0a7 --- /dev/null +++ b/core/pins/migrations/0005_pins_to_follows.py @@ -0,0 +1,83 @@ +from django.db import migrations + +# TBv2's "My Pins" listed a user's own pins plus the org pins they created (includeCreatorPins). TBv3 keeps pins as +# overview highlights and lists follows as personal shortcuts, so each person's list becomes follows. +RESOURCE_MODELS = [('orgs', 'organization'), ('sources', 'source'), ('collections', 'collection')] + + +def is_public(obj): + # 'Edit' is retired and reads as 'View' + return (obj.public_access or '').lower() in ('view', 'edit') + + +def can_view_org(user, org): + """CanViewConceptDictionary on an org: it's public, or the user is staff or a member.""" + return user.is_staff or is_public(org) or user.organizations.filter(id=org.id).exists() + + +def can_view_repo(user, repo): + """CanViewConceptDictionary on a repo: it's public (a version only while its HEAD is), or the user is staff, its + owner or a member of its org.""" + if user.is_staff: + return True + if is_public(repo): + head = repo if repo.version == 'HEAD' else type(repo).objects.filter( + mnemonic=repo.mnemonic, organization_id=repo.organization_id, user_id=repo.user_id, version='HEAD', + is_active=True).first() + if head is None or is_public(head): + return True + if repo.organization_id: + return user.organizations.filter(id=repo.organization_id).exists() + return repo.user_id == user.id + + +def get_pin_follows(apps): + """ + (follower id, content type id, resource id) for every pin, followed by the person whose TBv2 My Pins list showed + it: a user pin's user, or an org pin's creator. Skips resources that are gone or deleted, and pins that list no + longer showed: a resource, or the pinning org, the person can't view any more. + """ + follows = set() + pins = apps.get_model('pins', 'Pin').objects.select_related('resource_type', 'user', 'created_by', 'organization') + for pin in pins: + follower = pin.user or pin.created_by + content_type = pin.resource_type + if (content_type.app_label, content_type.model) not in RESOURCE_MODELS: + continue + resource = apps.get_model(content_type.app_label, content_type.model).objects.filter( + id=pin.resource_id, is_active=True).first() + can_view = can_view_org if content_type.model == 'organization' else can_view_repo + if resource and can_view(follower, resource) and ( + pin.organization is None or can_view_org(follower, pin.organization)): + follows.add((follower.id, content_type.id, resource.id)) + return follows + + +def create_follows_from_pins(apps, schema_editor): # pylint: disable=unused-argument + """Inserts the follows directly, not through UserProfile.follow(), so no Followed events are recorded. A follow + the person already has stays as it is.""" + follow_model = apps.get_model('users', 'Follow') + follow_model.objects.bulk_create( + [ + follow_model(follower_id=follower_id, following_type_id=type_id, following_id=resource_id) + for follower_id, type_id, resource_id in sorted(get_pin_follows(apps)) + ], + ignore_conflicts=True + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('pins', '0004_pin_created_by'), + ('users', '0037_userprofile_sso_id'), + ('orgs', '0022_retire_public_edit_access'), + ('sources', '0046_retire_public_edit_access'), + ('collections', '0071_retire_public_edit_access'), + ] + + operations = [ + # Pins stay as they are. Undoing it does nothing, as by then people may have changed their follows themselves. + migrations.RunPython(create_follows_from_pins, migrations.RunPython.noop), + ] diff --git a/core/pins/tests.py b/core/pins/tests.py index b8d9610b5..8c16d152e 100644 --- a/core/pins/tests.py +++ b/core/pins/tests.py @@ -1,15 +1,20 @@ +import importlib + from django.core.exceptions import ValidationError +from django.db import connection +from django.db.migrations.loader import MigrationLoader from mock import Mock -from core.collections.tests.factories import OrganizationCollectionFactory +from core.collections.tests.factories import OrganizationCollectionFactory, UserCollectionFactory from core.common.constants import ACCESS_TYPE_NONE from core.common.tests import OCLTestCase, OCLAPITestCase +from core.events.models import Event from core.orgs.models import Organization from core.orgs.tests.factories import OrganizationFactory from core.pins.models import Pin from core.sources.models import Source from core.sources.tests.factories import OrganizationSourceFactory -from core.users.models import UserProfile +from core.users.models import UserProfile, Follow from core.users.tests.factories import UserProfileFactory @@ -406,3 +411,68 @@ def test_put_200(self): user_pin2.refresh_from_db() self.assertEqual(self.user_pin.order, 1) self.assertEqual(user_pin2.order, 0) + + +class PinsToFollowsMigrationTest(OCLAPITestCase): + def test_migration_turns_my_pins_into_follows(self): # pylint: disable=too-many-locals + migration = importlib.import_module('core.pins.migrations.0005_pins_to_follows') + private_org = OrganizationFactory(public_access=ACCESS_TYPE_NONE) + public_org = OrganizationFactory() + member = UserProfileFactory(organizations=[private_org, public_org]) + former_member = UserProfileFactory() # left private_org + outsider = UserProfileFactory() + admin = UserProfile.objects.get(username='ocladmin') + public_source = OrganizationSourceFactory(organization=public_org) + public_collection = OrganizationCollectionFactory(organization=public_org) + public_version = OrganizationSourceFactory( + organization=public_org, mnemonic=public_source.mnemonic, version='v1') + private_source = OrganizationSourceFactory(organization=private_org, public_access=ACCESS_TYPE_NONE) + public_version_of_private_source = OrganizationSourceFactory( + organization=private_org, mnemonic=private_source.mnemonic, version='v1') + private_collection = UserCollectionFactory(user=member, public_access=ACCESS_TYPE_NONE) + deleted_source = OrganizationSourceFactory(organization=public_org) + inactive_collection = OrganizationCollectionFactory(organization=public_org, is_active=False) + + for resource in [ + public_source, private_source, private_collection, private_org, deleted_source, inactive_collection]: + member.pins.create(resource=resource) + for resource in [public_source, private_source, private_collection, private_org]: + former_member.pins.create(resource=resource) + for resource in [public_version, public_version_of_private_source]: + outsider.pins.create(resource=resource) + # org pins go to their creator, while the creator can view the org too + public_org.pins.create(resource=public_source, created_by=member) + public_org.pins.create(resource=public_collection, created_by=former_member) + public_org.pins.create(resource=private_source, created_by=former_member) + private_org.pins.create(resource=public_source, created_by=outsider) + private_org.pins.create(resource=private_collection) # created by ocladmin, a staff user + Source.objects.filter(id=deleted_source.id).delete() + existing_follow = Follow.objects.create(follower=member, following=public_source) + pins = list(Pin.objects.order_by('id').values()) + event_count = Event.objects.count() + + historical_apps = MigrationLoader(connection).project_state(('pins', '0005_pins_to_follows')).apps + migration.create_follows_from_pins(historical_apps, None) + migration.create_follows_from_pins(historical_apps, None) # running it again adds nothing + + def get_followed_uris(user): + return sorted(follow.following.uri for follow in user.following.all()) + + self.assertEqual( + get_followed_uris(member), + sorted([public_source.uri, private_source.uri, private_collection.uri, private_org.uri])) + self.assertEqual(get_followed_uris(former_member), sorted([public_source.uri, public_collection.uri])) + self.assertEqual(get_followed_uris(outsider), [public_version.uri]) + self.assertEqual(get_followed_uris(admin), [private_collection.uri]) + self.assertEqual(Follow.objects.count(), 8) + self.assertEqual(Follow.objects.get(follower=member, following_id=public_source.id).id, existing_follow.id) + self.assertEqual(list(Pin.objects.order_by('id').values()), pins) + self.assertEqual(Event.objects.count(), event_count) + + # the follows are what each person's TBv2 My Pins list showed, less the deleted resources + for user in [member, former_member, outsider, admin]: + response = self.client.get( + user.uri + 'pins/?includeCreatorPins=true', HTTP_AUTHORIZATION='Token ' + user.get_token()) + self.assertEqual( + sorted({pin['resource_uri'] for pin in response.data} - {None, inactive_collection.uri}), + get_followed_uris(user))