@@ -176,6 +176,124 @@ def is_project_submission_open(self) -> bool:
176176 return deadline is None or deadline >= timezone .now ()
177177
178178
179+ class Application (models .Model ):
180+ """
181+ MVP application model for participation in a partner program.
182+
183+ Team applications are intentionally deferred until the Team model exists.
184+ For now, an application is individual and must have `user`.
185+ """
186+
187+ STATUS_DRAFT = "draft"
188+ STATUS_SUBMITTED = "submitted"
189+ STATUS_APPROVED = "approved"
190+ STATUS_REJECTED = "rejected"
191+ STATUS_WITHDRAWN = "withdrawn"
192+ STATUS_CANCELLED = "cancelled"
193+
194+ ACTIVE_STATUSES = (
195+ STATUS_DRAFT ,
196+ STATUS_SUBMITTED ,
197+ STATUS_APPROVED ,
198+ )
199+
200+ STATUS_CHOICES = (
201+ (STATUS_DRAFT , "Draft" ),
202+ (STATUS_SUBMITTED , "Submitted" ),
203+ (STATUS_APPROVED , "Approved" ),
204+ (STATUS_REJECTED , "Rejected" ),
205+ (STATUS_WITHDRAWN , "Withdrawn" ),
206+ (STATUS_CANCELLED , "Cancelled" ),
207+ )
208+
209+ program = models .ForeignKey (
210+ PartnerProgram ,
211+ on_delete = models .CASCADE ,
212+ related_name = "applications" ,
213+ )
214+ user = models .ForeignKey (
215+ User ,
216+ on_delete = models .SET_NULL ,
217+ related_name = "program_applications" ,
218+ null = True ,
219+ blank = True ,
220+ )
221+ created_by = models .ForeignKey (
222+ User ,
223+ on_delete = models .PROTECT ,
224+ related_name = "created_program_applications" ,
225+ )
226+ status = models .CharField (
227+ max_length = 16 ,
228+ choices = STATUS_CHOICES ,
229+ default = STATUS_DRAFT ,
230+ )
231+ form_data = models .JSONField (default = dict , blank = True )
232+ project = models .ForeignKey (
233+ Project ,
234+ on_delete = models .SET_NULL ,
235+ related_name = "applications" ,
236+ null = True ,
237+ blank = True ,
238+ )
239+ submitted_at = models .DateTimeField (null = True , blank = True )
240+ approved_at = models .DateTimeField (null = True , blank = True )
241+ rejected_at = models .DateTimeField (null = True , blank = True )
242+ withdrawn_at = models .DateTimeField (null = True , blank = True )
243+ created_at = models .DateTimeField (auto_now_add = True )
244+ updated_at = models .DateTimeField (auto_now = True )
245+
246+ def clean (self ):
247+ super ().clean ()
248+
249+ errors = {}
250+
251+ if not self .user_id :
252+ errors ["user" ] = (
253+ "User is required for MVP applications until Team is implemented."
254+ )
255+
256+ if self .user_id and self .program_id and self .status in self .ACTIVE_STATUSES :
257+ duplicate_qs = Application .objects .filter (
258+ user_id = self .user_id ,
259+ program_id = self .program_id ,
260+ status__in = self .ACTIVE_STATUSES ,
261+ )
262+ if self .pk :
263+ duplicate_qs = duplicate_qs .exclude (pk = self .pk )
264+ if duplicate_qs .exists ():
265+ errors ["user" ] = (
266+ "User already has an active application for this program."
267+ )
268+
269+ if errors :
270+ raise ValidationError (errors )
271+
272+ def save (self , * args , ** kwargs ):
273+ self .full_clean ()
274+ return super ().save (* args , ** kwargs )
275+
276+ class Meta :
277+ verbose_name = "Application"
278+ verbose_name_plural = "Applications"
279+ constraints = [
280+ models .UniqueConstraint (
281+ fields = ["user" , "program" ],
282+ condition = models .Q (
283+ user__isnull = False ,
284+ status__in = ("draft" , "submitted" , "approved" ),
285+ ),
286+ name = "uniq_active_application_user_program" ,
287+ ),
288+ ]
289+
290+ def __str__ (self ):
291+ return (
292+ f"Application<{ self .pk } > "
293+ f"user={ self .user_id } program={ self .program_id } status={ self .status } "
294+ )
295+
296+
179297class PartnerProgramUserProfile (models .Model ):
180298 """
181299 PartnerProgramUserProfile model
0 commit comments