Coverage for src/ptf_tools/models.py: 85%

80 statements  

« prev     ^ index     » next       coverage.py v7.7.0, created at 2025-04-09 14:54 +0000

1import datetime 

2from dataclasses import asdict, dataclass, field 

3 

4from django.db import models 

5from django.http import HttpRequest 

6from invitations.app_settings import app_settings as invitations_app_settings 

7from invitations.models import Invitation as BaseInvitation 

8from ptf.models import Collection 

9 

10 

11class ResourceInNumdam(models.Model): 

12 pid = models.CharField(max_length=64, db_index=True) 

13 

14 

15class CollectionGroup(models.Model): 

16 """ 

17 Overwrites original Django Group. 

18 """ 

19 

20 def __str__(self): 

21 return self.group.name 

22 

23 group = models.OneToOneField("auth.Group", unique=True, on_delete=models.CASCADE) 

24 collections = models.ManyToManyField(Collection) 

25 email_alias = models.EmailField(max_length=70, blank=True, default="") 

26 

27 def get_collections(self) -> str: 

28 return ", ".join([col.pid for col in self.collections.all()]) 

29 

30 

31class Invitation(BaseInvitation): 

32 """ 

33 Invitation model. Additionally data can be stored in `extra_data`, to be used 

34 when an user signs up following the invitation link. 

35 Cf. signals.py 

36 """ 

37 

38 first_name = models.CharField("First name", max_length=150, null=False, blank=False) 

39 last_name = models.CharField("Last name", max_length=150, null=False, blank=False) 

40 extra_data = models.JSONField( 

41 default=dict, 

42 blank=True, 

43 help_text="JSON field used to dynamically update the created user object when the invitation is accepted.", 

44 ) 

45 

46 @classmethod 

47 def get_invite(cls, email: str, request: HttpRequest, invite_data: dict) -> "Invitation": 

48 """ 

49 Gets the existing valid invitation or creates a new one and send it. 

50 If there's an existing invitation but it's expired, we delete it and 

51 send a new one. 

52 

53 `invite_data` must contain `first_name` and `last_name` entries. It is passed 

54 as the context of the invite mail renderer. 

55 """ 

56 try: 

57 invite = cls.objects.get(email__iexact=email) 

58 # Delete the invite if it's expired and create a fresh one 

59 if invite.key_expired(): 

60 invite.delete() 

61 raise cls.DoesNotExist 

62 except cls.DoesNotExist: 

63 first_name = invite_data["first_name"] 

64 last_name = invite_data["last_name"] 

65 

66 invite = cls.create( 

67 email, inviter=request.user, first_name=first_name, last_name=last_name 

68 ) 

69 

70 mail_template_context = {**invite_data} 

71 mail_template_context["full_name"] = f"{first_name} {last_name}" 

72 invite.send_invitation(request, **mail_template_context) 

73 

74 return invite 

75 

76 def date_expired(self) -> datetime.datetime: 

77 return self.sent + datetime.timedelta( 

78 days=invitations_app_settings.INVITATION_EXPIRY, 

79 ) 

80 

81 

82@dataclass 

83class InviteCommentData: 

84 id: int 

85 user_id: int 

86 pid: str 

87 doi: str 

88 

89 

90@dataclass 

91class InviteCollectionData: 

92 pid: list[str] 

93 user_id: int 

94 

95 

96@dataclass 

97class InviteModeratorData: 

98 """ 

99 Interface for storing the moderator data in an invitation. 

100 """ 

101 

102 comments: list[InviteCommentData] = field(default_factory=list) 

103 collections: list[InviteCollectionData] = field(default_factory=list) 

104 

105 def __post_init__(self): 

106 try: 

107 comments = self.comments 

108 if not isinstance(comments, list): 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true

109 raise ValueError("'comments' must be a list") 

110 self.comments = [ 

111 InviteCommentData(**c) if not isinstance(c, InviteCommentData) else c 

112 for c in comments 

113 ] 

114 except Exception as e: 

115 raise ValueError(f"Error while parsing provided InviteCommentData. {str(e)}") 

116 

117 try: 

118 collections = self.collections 

119 if not isinstance(collections, list): 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 raise ValueError("'collections' must be a list") 

121 self.collections = [ 

122 InviteCollectionData(**c) if not isinstance(c, InviteCollectionData) else c 

123 for c in collections 

124 ] 

125 except Exception as e: 

126 raise ValueError(f"Error while parsing provided InviteCollectionData. {str(e)}") 

127 

128 

129@dataclass 

130class InvitationExtraData: 

131 """ 

132 Interface representing an invitation's extra data. 

133 """ 

134 

135 moderator: InviteModeratorData = field(default_factory=InviteModeratorData) 

136 user_groups: list[int] = field(default_factory=list) 

137 

138 def __post_init__(self): 

139 """ 

140 Dataclasses do not provide an effective fromdict method to deserialize 

141 a dataclass (JSON to python dataclass object). 

142 

143 This enables to effectively deserialize a JSON into a InvitationExtraData object, 

144 by replacing the nested dict by their actual dataclass representation. 

145 Beware this might not work well with typing (?) 

146 """ 

147 moderator = self.moderator 

148 if moderator and not isinstance(moderator, InviteModeratorData): 

149 try: 

150 self.moderator = InviteModeratorData(**moderator) 

151 except Exception as e: 

152 raise ValueError(f"Error while parsing provided InviteModeratorData. {str(e)}") 

153 

154 def serialize(self) -> dict: 

155 return asdict(self)