forked from CenterForOpenScience/osf.io
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathnode.py
More file actions
2658 lines (2295 loc) · 102 KB
/
node.py
File metadata and controls
2658 lines (2295 loc) · 102 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import functools
import itertools
import logging
import re
from urllib.parse import urljoin
import warnings
from rest_framework import status as http_status
import bson
from django.db.models import Q
from dirtyfields import DirtyFieldsMixin
from django.apps import apps
from django.contrib.auth.models import AnonymousUser, Permission
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ImproperlyConfigured
from django.core.paginator import Paginator
from django.urls import reverse
from django.db import models, connection, IntegrityError
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from django.utils.functional import cached_property
from psycopg2._psycopg import AsIs
from typedmodels.models import TypedModel, TypedModelManager
from guardian.models import (
GroupObjectPermissionBase,
UserObjectPermissionBase,
)
from guardian.shortcuts import get_objects_for_user
from framework import status
from framework.auth import oauth_scopes
from framework.celery_tasks.handlers import enqueue_task, get_task_from_queue
from framework.exceptions import PermissionsError, HTTPError
from framework.sentry import log_exception
from osf.exceptions import InvalidTagError, NodeStateError, TagNotFoundError, ValidationError
from osf.models.notification_type import NotificationTypeEnum
from .contributor import Contributor
from .collection_submission import CollectionSubmission
from .identifiers import Identifier, IdentifierMixin
from .licenses import NodeLicenseRecord
from .metadata import GuidMetadataRecord
from .mixins import (AddonModelMixin, CommentableMixin, Loggable, GuardianMixin,
NodeLinkMixin, SpamOverrideMixin, RegistrationResponseMixin,
EditableFieldsMixin, ShareIndexMixin)
from .node_relation import NodeRelation
from .nodelog import NodeLog
from .private_link import PrivateLink
from .tag import Tag
from .user import OSFUser
from .validators import validate_title, validate_doi
from framework.auth.core import Auth
from osf.external.gravy_valet import (
request_helpers as gv_requests,
translations as gv_translations,
)
from osf.utils.datetime_aware_jsonfield import DateTimeAwareJSONField
from osf.utils.fields import NonNaiveDateTimeField
from osf.utils.requests import get_request_and_user_id, string_type_request_headers, get_current_request
from osf.utils.workflows import CollectionSubmissionStates
from osf.utils import sanitize
from website import language, settings
from website.citations.utils import datetime_to_csl
from website.project import signals as project_signals
from website.project import tasks as node_tasks
from website.project.model import NodeUpdateError
from website.identifiers.tasks import update_doi_metadata_on_change
from website.identifiers.clients import DataCiteClient
from osf.utils.permissions import (
ADMIN,
ADMIN_NODE,
CREATOR_PERMISSIONS,
PERMISSIONS,
READ,
WRITE_NODE,
READ_NODE,
WRITE
)
from website.util.metrics import OsfSourceTags, CampaignSourceTags
from website.util import api_url_for, api_v2_url, web_url_for
from .base import BaseModel, GuidMixin, GuidMixinQuerySet, check_manually_assigned_guid
from api.base.exceptions import Conflict
from api.caching.tasks import update_storage_usage
from api.caching import settings as cache_settings
from api.caching.utils import storage_usage_cache
logger = logging.getLogger(__name__)
class AbstractNodeQuerySet(GuidMixinQuerySet):
def get_roots(self):
return self.filter(
id__in=self.exclude(type__in=['osf.collection', 'osf.draftnode']).values_list(
'root_id', flat=True))
def get_children(self, root, active=False, include_root=False):
# If `root` is a root node, we can use the 'descendants' related name
# rather than doing a recursive query
if root.id == root.root_id:
query = root.descendants.all() if include_root else root.descendants.exclude(id=root.id)
if active:
query = query.filter(is_deleted=False)
return query
else:
sql = """
WITH RECURSIVE descendants AS (
SELECT
parent_id,
child_id,
1 AS LEVEL,
ARRAY[parent_id] as pids
FROM %s
%s
WHERE is_node_link IS FALSE AND parent_id = %s %s
UNION ALL
SELECT
d.parent_id,
s.child_id,
d.level + 1,
d.pids || s.parent_id
FROM descendants AS d
JOIN %s AS s
ON d.child_id = s.parent_id
WHERE s.is_node_link IS FALSE AND %s = ANY(pids)
) SELECT array_agg(DISTINCT child_id)
FROM descendants
WHERE parent_id = %s;
"""
with connection.cursor() as cursor:
node_relation_table = AsIs(NodeRelation._meta.db_table)
cursor.execute(sql, [
node_relation_table,
AsIs(
f'LEFT JOIN osf_abstractnode ON {node_relation_table}.child_id = osf_abstractnode.id' if active else ''),
root.pk,
AsIs('AND osf_abstractnode.is_deleted IS FALSE' if active else ''),
node_relation_table,
root.pk,
root.pk])
row = cursor.fetchone()[0]
if not row:
return AbstractNode.objects.filter(id=root.pk) if include_root else AbstractNode.objects.none()
if include_root:
row.append(root.pk)
return AbstractNode.objects.filter(id__in=row)
def can_view(self, user=None, private_link=None, **custom_filters):
if private_link is not None:
if isinstance(private_link, PrivateLink):
private_link = private_link.key
if not isinstance(private_link, str):
raise TypeError(f'"private_link" must be either {str} or {PrivateLink}. Got {private_link!r}')
return self.filter(private_links__is_deleted=False, private_links__key=private_link).filter(
is_deleted=False)
# By default, only public nodes are shown. However, custom filters can be provided.
# This is useful when you want to display a specific subset of nodes unrelated to
# the current user (e.g. only `pending` nodes for moderators).
qs = self.filter(is_public=True) if not custom_filters else self.filter(**custom_filters)
if user is not None and not isinstance(user, AnonymousUser):
qs |= get_objects_for_user(user, READ_NODE, self, with_superuser=False)
qs |= self.extra(where=["""
"osf_abstractnode".id in (
WITH RECURSIVE implicit_read AS (
SELECT N.id as node_id
FROM osf_abstractnode as N, auth_permission as P, osf_nodegroupobjectpermission as G, osf_osfuser_groups as UG
WHERE P.codename = 'admin_node'
AND G.permission_id = P.id
AND UG.osfuser_id = %s
AND G.group_id = UG.group_id
AND G.content_object_id = N.id
AND N.type = 'osf.node'
UNION ALL
SELECT "osf_noderelation"."child_id"
FROM "implicit_read"
LEFT JOIN "osf_noderelation" ON "osf_noderelation"."parent_id" = "implicit_read"."node_id"
WHERE "osf_noderelation"."is_node_link" IS FALSE
) SELECT * FROM implicit_read
)
"""], params=(user.id,))
return qs.filter(is_deleted=False)
class AbstractNodeManager(TypedModelManager):
def get_queryset(self):
qs = AbstractNodeQuerySet(self.model, using=self._db)
# Filter by typedmodels type
return self._filter_by_type(qs)
# AbstractNodeQuerySet methods
def get_roots(self):
return self.get_queryset().get_roots()
def get_children(self, root, active=False, include_root=False):
return self.get_queryset().get_children(root, active=active, include_root=include_root)
def can_view(self, user=None, private_link=None):
return self.get_queryset().can_view(user=user, private_link=private_link)
def get_nodes_for_user(self, user, permission=READ_NODE, base_queryset=None, include_public=False):
"""
Return all AbstractNodes that the user has permissions to - either through contributorship or group membership.
- similar to guardian.get_objects_for_user(self, READ_NODE, AbstractNode, with_superuser=False). If include_public is True,
queryset is expanded to include public nodes.
:param User user: User object to check
:param permission: Permission string to check, official perm, i.e. 'read_node', 'write_node', 'admin_node'
:param base_queryset: If filtering on a smaller queryset is desired, pass in a starting queryset
:param include_public: If True, will include public nodes in query that user may not have explicit perms to
:returns node queryset that the user has perms to
"""
OSFUserGroup = apps.get_model('osf', 'osfuser_groups')
if base_queryset is None:
base_queryset = self
if permission not in PERMISSIONS:
raise ValueError(f'Permission must be one of {PERMISSIONS[0]}, {PERMISSIONS[1]}, or {PERMISSIONS[2]}.')
nodes = base_queryset.filter(is_deleted=False)
permission_object_id = Permission.objects.get(codename=permission).id
user_groups = OSFUserGroup.objects.filter(osfuser_id=user.id if user else None).values_list('group_id',
flat=True)
node_groups = NodeGroupObjectPermission.objects.filter(group_id__in=user_groups,
permission_id=permission_object_id).values_list(
'content_object_id', flat=True)
query = Q(id__in=node_groups)
if include_public:
query |= Q(is_public=True)
return nodes.filter(query)
class AbstractNode(DirtyFieldsMixin, TypedModel, AddonModelMixin, IdentifierMixin, EditableFieldsMixin, GuardianMixin,
NodeLinkMixin, CommentableMixin, SpamOverrideMixin, Loggable, GuidMixin, RegistrationResponseMixin,
ShareIndexMixin, BaseModel):
"""
All things that inherit from AbstractNode will appear in
the same table and will be differentiated by the `type` column.
"""
#: Whether this is a pointer or not
primary = True
settings_type = 'node' # Needed for addons
FIELD_ALIASES = {
# TODO: Find a better way
'_id': 'guids___id',
'nodes': '_nodes',
'contributors': '_contributors',
}
# Node fields that trigger an update to Solr on save
SEARCH_UPDATE_FIELDS = {
'title',
'category',
'description',
'is_fork',
'retraction',
'embargo',
'is_public',
'is_deleted',
'node_license',
}
# Node fields that trigger an identifier update on save
IDENTIFIER_UPDATE_FIELDS = {
'title',
'description',
'is_public',
'contributors',
'is_deleted',
'node_license'
}
# Node fields that trigger a check to the spam filter on save
SPAM_CHECK_FIELDS = {
'title',
'description',
}
SPAM_ADDONS = {
'forward': 'addons_forward_node_settings__url',
'wiki': 'wikis__versions__content'
}
# Fields that are writable by Node.update
WRITABLE_WHITELIST = [
'title',
'description',
'category',
'is_public',
'node_license',
]
# Named constants
PRIVATE = 'private'
PUBLIC = 'public'
LICENSE_QUERY = re.sub(r'\s+', ' ', """WITH RECURSIVE ascendants AS (
SELECT
N.node_license_id,
R.parent_id
FROM "{noderelation}" AS R
JOIN "{abstractnode}" AS N ON N.id = R.parent_id
WHERE R.is_node_link IS FALSE
AND R.child_id = %s
UNION ALL
SELECT
N.node_license_id,
R.parent_id
FROM ascendants AS D
JOIN "{noderelation}" AS R ON D.parent_id = R.child_id
JOIN "{abstractnode}" AS N ON N.id = R.parent_id
WHERE R.is_node_link IS FALSE
AND D.node_license_id IS NULL
) SELECT {fields} FROM "{nodelicenserecord}"
WHERE id = (SELECT node_license_id FROM ascendants WHERE node_license_id IS NOT NULL) LIMIT 1;""")
_contributors = models.ManyToManyField(OSFUser,
through=Contributor,
related_name='nodes')
creator = models.ForeignKey(OSFUser,
db_index=True,
related_name='nodes_created',
on_delete=models.SET_NULL,
null=True, blank=True)
deleted_date = NonNaiveDateTimeField(null=True, blank=True)
deleted = NonNaiveDateTimeField(null=True, blank=True)
file_guid_to_share_uuids = DateTimeAwareJSONField(default=dict, blank=True)
forked_date = NonNaiveDateTimeField(db_index=True, null=True, blank=True)
forked_from = models.ForeignKey('self',
related_name='forks',
on_delete=models.SET_NULL,
null=True, blank=True)
is_fork = models.BooleanField(default=False, db_index=True)
is_public = models.BooleanField(default=False, db_index=True)
is_deleted = models.BooleanField(default=False, db_index=True)
access_requests_enabled = models.BooleanField(null=True, blank=True, default=True, db_index=True)
custom_citation = models.TextField(blank=True, null=True)
# One of 'public', 'private'
# TODO: Add validator
comment_level = models.CharField(default='public', max_length=10)
root = models.ForeignKey('AbstractNode',
default=None,
related_name='descendants',
on_delete=models.SET_NULL, null=True, blank=True)
_nodes = models.ManyToManyField('AbstractNode',
through=NodeRelation,
through_fields=('parent', 'child'),
related_name='parent_nodes')
files = GenericRelation('osf.OsfStorageFile', object_id_field='target_object_id',
content_type_field='target_content_type')
# For ContributorMixin
guardian_object_type = 'node'
# For ContributorMixin
base_perms = PERMISSIONS
groups = {
'read': (READ_NODE,),
'write': (READ_NODE, WRITE_NODE,),
'admin': (READ_NODE, WRITE_NODE, ADMIN_NODE,)
}
group_format = 'node_{self.id}_{group}'
article_doi = models.CharField(max_length=128,
validators=[validate_doi],
null=True, blank=True)
custom_storage_usage_limit_public = models.DecimalField(decimal_places=9, max_digits=100, null=True, blank=True)
custom_storage_usage_limit_private = models.DecimalField(decimal_places=9, max_digits=100, null=True, blank=True)
schema_responses = GenericRelation('osf.SchemaResponse', related_query_name='nodes')
class Meta:
base_manager_name = 'objects'
index_together = (('is_public', 'is_deleted', 'type'))
permissions = (
('view_node', 'Can view node details'),
('read_node', 'Can read the node'),
('write_node', 'Can edit the node'),
('admin_node', 'Can manage the node'),
)
objects = AbstractNodeManager()
@cached_property
def parent_node(self):
try:
node_rel = self._parents.filter(is_node_link=False)[0]
except IndexError:
node_rel = None
if node_rel:
parent = node_rel.parent
if parent:
return parent
return None
@property
def storage_limit_status(self):
""" This should indicate if a node is at or over a certain storage threshold indicating a status. If nodes have
a custom limit this should indicate that."""
return settings.StorageLimits.from_node_usage(
self.storage_usage,
self.custom_storage_usage_limit_private,
self.custom_storage_usage_limit_public
)
@property
def nodes(self):
"""Return queryset of nodes."""
return self.get_nodes()
@property
def node_ids(self):
return list(self._nodes.all().values_list('guids___id', flat=True))
@property
def linked_from(self):
"""Return the nodes that have linked to this node."""
return self.parent_nodes.filter(node_relations__is_node_link=True)
@property
def linked_from_collections(self):
"""Return the collections that have linked to this node."""
return self.linked_from.filter(type='osf.collection')
def get_nodes(self, **kwargs):
"""Return list of children nodes. ``kwargs`` are used to filter against
children. In addition `is_node_link=<bool>` can be passed to filter against
node links.
"""
# Prepend 'child__' to kwargs for filtering
filter_kwargs = {}
if 'is_node_link' in kwargs:
filter_kwargs['is_node_link'] = kwargs.pop('is_node_link')
for key, val in kwargs.items():
filter_kwargs[f'child__{key}'] = val
node_relations = (NodeRelation.objects.filter(parent=self, **filter_kwargs)
.select_related('child')
.order_by('_order'))
return [each.child for each in node_relations]
@property
def linked_nodes(self):
child_pks = NodeRelation.objects.filter(
parent=self,
is_node_link=True
).select_related('child').values_list('child', flat=True)
return self._nodes.filter(pk__in=child_pks)
# permissions = Permissions are now on contributors
piwik_site_id = models.IntegerField(null=True, blank=True)
suspended = models.BooleanField(default=False, db_index=True)
# The node (if any) used as a template for this node's creation
template_node = models.ForeignKey('self',
related_name='templated_from',
on_delete=models.SET_NULL,
null=True, blank=True)
# Dictionary field mapping node wiki page to sharejs private uuid.
# {<page_name>: <sharejs_id>}
wiki_private_uuids = DateTimeAwareJSONField(default=dict, blank=True)
identifiers = GenericRelation(Identifier, related_query_name='nodes')
def __init__(self, *args, **kwargs):
self._parent = kwargs.pop('parent', None)
self._is_templated_clone = False
super().__init__(*args, **kwargs)
def __repr__(self):
return ('(title={self.title!r}, category={self.category!r}) '
'with guid {self._id!r}').format(self=self)
@property
def is_registration(self):
"""For v1 compat."""
return False
@property
def is_original(self):
return not self.is_registration and not self.is_fork
@property
def collection_submissions(self):
return CollectionSubmission.objects.filter(
guid=self.guids.first(),
collection__provider__isnull=False,
collection__deleted__isnull=True,
collection__is_bookmark_collection=False,
)
@property
def has_linked_published_preprints(self):
# Node holds supplemental material for published preprint(s)
Preprint = apps.get_model('osf.Preprint')
return self.preprints.filter(Preprint.objects.no_user_query).exists()
@property
def is_collection(self):
"""For v1 compat"""
return False
@property # TODO Separate out for submodels
def absolute_api_v2_url(self):
if self.is_registration:
path = f'/registrations/{self._id}/'
elif self.is_collection:
path = f'/collections/{self._id}/'
elif self.type == 'osf.draftnode':
path = f'/draft_nodes/{self._id}/'
else:
path = f'/nodes/{self._id}/'
return api_v2_url(path)
@property
def absolute_url(self):
if not self.url:
return None
return urljoin(settings.DOMAIN, self.url)
@property
def deep_url(self):
return f'/project/{self._primary_key}/'
@property
def sanction(self):
"""For v1 compat. Registration has the proper implementation of this property."""
return None
@property
def is_retracted(self):
"""For v1 compat."""
return False
@property
def is_pending_registration(self):
"""For v1 compat."""
return False
@property
def is_pending_retraction(self):
"""For v1 compat."""
return False
@property
def is_pending_embargo(self):
"""For v1 compat."""
return False
@property
def is_embargoed(self):
"""For v1 compat."""
return False
@property
def archiving(self):
"""For v1 compat."""
return False
@property
def embargo_end_date(self):
"""For v1 compat."""
return False
@property
def forked_from_guid(self):
if self.forked_from:
return self.forked_from._id
return None
@property
def linked_nodes_self_url(self):
return self.absolute_api_v2_url + 'relationships/linked_nodes/'
@property
def linked_registrations_self_url(self):
return self.absolute_api_v2_url + 'relationships/linked_registrations/'
@property
def linked_nodes_related_url(self):
return self.absolute_api_v2_url + 'linked_nodes/'
@property
def linked_registrations_related_url(self):
return self.absolute_api_v2_url + 'linked_registrations/'
@property
def institutions_url(self):
return self.absolute_api_v2_url + 'institutions/'
@property
def institutions_relationship_url(self):
return self.absolute_api_v2_url + 'relationships/institutions/'
@property
def callbacks_url(self):
return self.absolute_api_v2_url + 'callbacks/'
# For Comment API compatibility
@property
def target_type(self):
"""The object "type" used in the OSF v2 API."""
return 'nodes'
@property
def root_target_page(self):
"""The comment page type associated with Nodes."""
Comment = apps.get_model('osf.Comment')
return Comment.OVERVIEW
def belongs_to_node(self, node_id):
"""Check whether this node matches the specified node."""
return self._id == node_id
@property
def category_display(self):
"""The human-readable representation of this node's category."""
return settings.NODE_CATEGORY_MAP[self.category]
@property
def url(self):
return f'/{self._primary_key}/'
@property
def api_url(self):
if not self.url:
logger.error(f'Node {self._id} has a parent that is not a project')
return None
return f'/api/v1{self.deep_url}'
@property
def display_absolute_url(self):
url = self.absolute_url
if url is not None:
return re.sub(r'https?:', '', url).strip('/')
@property
def nodes_active(self):
return self._nodes.filter(is_deleted=False)
def web_url_for(self, view_name, _absolute=False, _guid=False, *args, **kwargs):
return web_url_for(view_name, pid=self._primary_key,
_absolute=_absolute, _guid=_guid, *args, **kwargs)
def api_url_for(self, view_name, _absolute=False, *args, **kwargs):
return api_url_for(view_name, pid=self._primary_key, _absolute=_absolute, *args, **kwargs)
def api_v2_url_for(self, path_str, params=None, **kwargs):
return api_url_for(path_str, params=params, **kwargs)
@property
def project_or_component(self):
# The distinction is drawn based on whether something has a parent node, rather than by category
return 'project' if not self.parent_node else 'component'
@property
def templated_list(self):
return self.templated_from.filter(is_deleted=False)
@property
def draft_registrations_active(self):
DraftRegistration = apps.get_model('osf.DraftRegistration')
return DraftRegistration.objects.filter(
models.Q(branched_from=self) &
models.Q(deleted__isnull=True) &
(models.Q(registered_node=None) | models.Q(registered_node__deleted__isnull=False)),
)
@property
def has_active_draft_registrations(self):
return self.draft_registrations_active.exists()
@property
def csl(self): # formats node information into CSL format for citation parsing
"""a dict in CSL-JSON schema
For details on this schema, see:
https://github.com/citation-style-language/schema#csl-json-schema
"""
csl = {
'id': self._id,
'title': sanitize.unescape_entities(self.title),
'author': [
contributor.csl_name(self._id) # method in auth/model.py which parses the names of authors
for contributor in self.visible_contributors
],
'publisher': 'OSF',
'type': 'webpage',
'URL': self.display_absolute_url,
}
doi = self.get_identifier_value('doi')
if doi:
csl['DOI'] = doi
if self.registered_date:
csl['issued'] = datetime_to_csl(self.registered_date)
else:
if self.logs.exists():
csl['issued'] = datetime_to_csl(self.logs.latest().date)
return csl
@property
def should_request_identifiers(self):
return not self.all_tags.filter(name='qatest').exists()
@classmethod
def bulk_update_search(cls, nodes, index=None):
from api.share.utils import update_share
for _node in nodes:
update_share(_node)
from website.search import search, exceptions
try:
serialize = functools.partial(search.update_node, index=index, bulk=True, async_update=False)
search.bulk_update_nodes(serialize, nodes, index=index)
except exceptions.SearchUnavailableError as e:
logger.exception(e)
log_exception(e)
def update_search(self):
from api.share.utils import update_share
update_share(self)
from website.search import search, exceptions
try:
search.update_node(self, bulk=False, async_update=True)
if self.collection_submissions.exists() and self.is_public:
search.update_collected_metadata(self._id)
except exceptions.SearchUnavailableError as e:
logger.exception(e)
log_exception(e)
def delete_search_entry(self):
from website.search import search, exceptions
try:
search.delete_node(self)
except exceptions.SearchUnavailableError as e:
logger.exception(e)
log_exception(e)
@classmethod
def find_by_institutions(cls, inst, query=None):
return inst.nodes.filter(query) if query else inst.nodes.all()
def _is_embargo_date_valid(self, end_date):
now = timezone.now()
if (end_date - now) >= settings.EMBARGO_END_DATE_MIN:
if (end_date - now) <= settings.EMBARGO_END_DATE_MAX:
return True
return False
def can_view(self, auth):
if auth and getattr(auth.private_link, 'anonymous', False):
return auth.private_link.nodes.filter(pk=self.pk).exists()
if not auth and not self.is_public:
return False
return (self.is_public or
(auth.user and self.has_permission(auth.user, READ)) or
auth.private_key in self.private_link_keys_active or
self.is_admin_parent(auth.user))
def can_edit(self, auth=None, user=None):
"""Return if a user is authorized to edit this node.
Must specify one of (`auth`, `user`).
:param Auth auth: Auth object to check
:param User user: User object to check
:returns: Whether user has permission to edit this node.
"""
if not auth and not user:
raise ValueError('Must pass either `auth` or `user`')
if auth and user:
raise ValueError('Cannot pass both `auth` and `user`')
user = user or auth.user
if auth:
is_api_node = auth.api_node == self
else:
is_api_node = False
return (user and self.has_permission(user, WRITE)) or is_api_node
def get_logs_queryset(self, auth):
return NodeLog.objects.filter(
node_id=self.id,
should_hide=False
).order_by('-date').prefetch_related(
'node__guids', 'user__guids', 'original_node__guids',
)
def get_absolute_url(self):
return self.absolute_api_v2_url
def has_permission_on_children(self, user, permission):
"""Checks if the given user has a given permission on any child nodes
that are not registrations or deleted
"""
if self.has_permission(user, permission):
return True
for node in self.nodes_primary.filter(is_deleted=False):
if node.has_permission_on_children(user, permission):
return True
return False
def is_admin_parent(self, user, include_group_admin=True):
"""
:param user: OSFUser to check for admin permissions
:param bool include_group_admin: Check if a user is an admin on the parent project via a group.
Useful for checking parent permissions for non-group actions like registrations.
:return: bool Does the user have admin permissions on this object or its parents?
"""
if self.has_permission(user, ADMIN, check_parent=False):
ret = True
if not include_group_admin and not self.is_contributor(user):
ret = False
return ret
parent = self.parent_node
if parent:
return parent.is_admin_parent(user, include_group_admin=include_group_admin)
return False
def find_readable_descendants(self, auth):
""" Returns a generator of first descendant node(s) readable by <user>
in each descendant branch.
"""
new_branches = []
for node in self.nodes_primary.filter(is_deleted=False):
if node.can_view(auth):
yield node
else:
new_branches.append(node)
for bnode in new_branches:
for node in bnode.find_readable_descendants(auth):
yield node
@property
def parents(self):
if self.parent_node:
return [self.parent_node] + self.parent_node.parents
return []
def get_users_with_perm(self, permission):
# Returns queryset of all User objects with a specific permission for the given node
# Can either have these perms through contributorship or group membership.
# Implicit admin not included here, and superusers not included.
if permission not in self.groups:
return False
perm = Permission.objects.get(codename=f'{permission}_node')
node_group_objects = NodeGroupObjectPermission.objects.filter(permission_id=perm.id,
content_object_id=self.id).values_list('group_id',
flat=True)
return OSFUser.objects.filter(groups__id__in=node_group_objects).distinct('id', 'family_name')
@property
def admin_contributor_or_group_member_ids(self):
# Overrides ContributorMixin
# Admin contributors or group members on parent, or current resource,
# Called when removing project subscriptions.
return self._get_admin_user_ids(include_self=True)
@property
def parent_admin_contributor_ids(self):
"""
Contributors who have admin permissions on a parent (excludes group members),
and by default, don't have perms on the current node
"""
return self._get_admin_contributor_ids()
def _get_admin_contributor_ids(self, include_self=False):
def get_admin_contributor_ids(node):
return node.get_group(ADMIN).user_set.filter(is_active=True).values_list('guids___id', flat=True)
contributor_ids = set(self.contributors.values_list('guids___id', flat=True))
admin_ids = set(get_admin_contributor_ids(self)) if include_self else set()
for parent in self.parents:
admins = get_admin_contributor_ids(parent)
admin_ids.update(set(admins).difference(contributor_ids))
return admin_ids
@property
def parent_admin_contributors(self):
"""
Returns node contributors who are admins on the parent node and not the current
node (excludes group members)
"""
return OSFUser.objects.filter(
guids___id__in=self.parent_admin_contributor_ids
).order_by('family_name')
@property
def parent_admin_user_ids(self):
return self._get_admin_user_ids()
def _get_admin_user_ids(self, include_self=False):
def get_admin_user_ids(node):
return node.get_users_with_perm(ADMIN).values_list('guids___id', flat=True)
contributor_ids = set(self.get_users_with_perm(READ).values_list('guids___id', flat=True))
admin_ids = set(get_admin_user_ids(self)) if include_self else set()
for parent in self.parents:
admins = get_admin_user_ids(parent)
admin_ids.update(set(admins).difference(contributor_ids))
return admin_ids
@property
def parent_admin_users(self):
"""
Returns users who are admins on the parent node (and not the current node)
Includes contributors and members of OSF Groups
"""
return OSFUser.objects.filter(
guids___id__in=self.parent_admin_user_ids
).order_by('family_name')
@property
def contributors_and_group_members(self):
"""
Returns a queryset of all users who are either contributors
on the node, or have permission through OSFGroup membership
"""
return self.get_users_with_perm(READ)
@property
def registrations_all(self):
"""For v1 compat."""
return self.registrations.all()
@cached_property
def osfstorage_region(self):
from addons.osfstorage.models import Region
osfs_settings = self._settings_model('osfstorage')
region_subquery = osfs_settings.objects.filter(owner=self.id).values_list('region_id', flat=True)[0]
return Region.objects.get(id=region_subquery)
@property
def parent_id(self):
if hasattr(self, 'annotated_parent_id'):
# If node has been annotated with "annotated_parent_id"
# in a queryset, use that value. Otherwise, fetch the parent_node guid.
return self.annotated_parent_id
else:
if self.parent_node:
return self.parent_node._id
return None
@property
def license(self):
if self.node_license_id:
return self.node_license
with connection.cursor() as cursor:
cursor.execute(self.LICENSE_QUERY.format(
abstractnode=AbstractNode._meta.db_table,
noderelation=NodeRelation._meta.db_table,
nodelicenserecord=NodeLicenseRecord._meta.db_table,
fields=', '.join(f'"{NodeLicenseRecord._meta.db_table}"."{f.column}"' for f in
NodeLicenseRecord._meta.concrete_fields)
), [self.id])
res = cursor.fetchone()
if res:
return NodeLicenseRecord.from_db(self._state.db, None, res)
return None
@property
def all_tags(self):
"""Return a queryset containing all of this node's tags (incl. system tags)."""
# Tag's default manager only returns non-system tags, so we can't use self.tags
return Tag.all_tags.filter(abstractnode_tagged=self)
@property
def system_tags_objects(self):
return self.all_tags.filter(system=True)
@property
def system_tags(self):
"""The system tags associated with this node. This currently returns a list of string
names for the tags, for compatibility with v1. Eventually, we can just return the
QuerySet.
"""
return self.system_tags_objects.values_list('name', flat=True)
# Override Taggable