From 833ac23d5f87a66d245a39df53277d3857d23103 Mon Sep 17 00:00:00 2001 From: Jonathan Payne Date: Thu, 22 Oct 2026 13:53:52 -0400 Subject: [PATCH] OpenConceptLab/ocl_issues#2947 | Data migration: each person's TBv2 My Pins become follows For every pin, follow the pinned org or repo for the person whose TBv2 My Pins list showed it (a user pin's user, an org pin's creator), unless it's gone or they can no longer view it. Follows are inserted directly, so no Followed events are recorded. Pins are untouched, and the reverse migration does nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TjM9dGow2KV1Gwr2dWQdph --- core/pins/migrations/0005_pins_to_follows.py | 83 ++++++++++++++++++++ core/pins/tests.py | 74 ++++++++++++++++- 2 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 core/pins/migrations/0005_pins_to_follows.py 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))