lass="lines-num lines-num-new">
210
+
|
|
|
211
|
+ recycle_core_models_WeighTicket [label=<
|
|
|
212
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
213
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
214
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
215
|
+ WeighTicket<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
216
|
+ </B></FONT></TD></TR>
|
|
|
217
|
+
|
|
|
218
|
+ </TABLE>
|
|
|
219
|
+ >]
|
|
|
220
|
+
|
|
|
221
|
+ recycle_core_models_WeighLine [label=<
|
|
|
222
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
223
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
224
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
225
|
+ WeighLine<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
226
|
+ </B></FONT></TD></TR>
|
|
|
227
|
+
|
|
|
228
|
+ </TABLE>
|
|
|
229
|
+ >]
|
|
|
230
|
+
|
|
|
231
|
+ recycle_core_models_Invoice [label=<
|
|
|
232
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
233
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
234
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
235
|
+ Invoice<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
236
|
+ </B></FONT></TD></TR>
|
|
|
237
|
+
|
|
|
238
|
+ </TABLE>
|
|
|
239
|
+ >]
|
|
|
240
|
+
|
|
|
241
|
+ recycle_core_models_InvoiceLine [label=<
|
|
|
242
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
243
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
244
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
245
|
+ InvoiceLine<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
246
|
+ </B></FONT></TD></TR>
|
|
|
247
|
+
|
|
|
248
|
+ </TABLE>
|
|
|
249
|
+ >]
|
|
|
250
|
+
|
|
|
251
|
+ recycle_core_models_Payment [label=<
|
|
|
252
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
253
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
254
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
255
|
+ Payment<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
256
|
+ </B></FONT></TD></TR>
|
|
|
257
|
+
|
|
|
258
|
+ </TABLE>
|
|
|
259
|
+ >]
|
|
|
260
|
+
|
|
|
261
|
+ recycle_core_models_Payout [label=<
|
|
|
262
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
263
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
264
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
265
|
+ Payout<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
266
|
+ </B></FONT></TD></TR>
|
|
|
267
|
+
|
|
|
268
|
+ </TABLE>
|
|
|
269
|
+ >]
|
|
|
270
|
+
|
|
|
271
|
+ recycle_core_models_Document [label=<
|
|
|
272
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
273
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
274
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
275
|
+ Document<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
276
|
+ </B></FONT></TD></TR>
|
|
|
277
|
+
|
|
|
278
|
+ </TABLE>
|
|
|
279
|
+ >]
|
|
|
280
|
+
|
|
|
281
|
+ recycle_core_models_AuditLog [label=<
|
|
|
282
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
283
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
284
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
285
|
+ AuditLog<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
286
|
+ </B></FONT></TD></TR>
|
|
|
287
|
+
|
|
|
288
|
+ </TABLE>
|
|
|
289
|
+ >]
|
|
|
290
|
+
|
|
|
291
|
+ recycle_core_models_ScrapListing [label=<
|
|
|
292
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
293
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
294
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
295
|
+ ScrapListing<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
296
|
+ </B></FONT></TD></TR>
|
|
|
297
|
+
|
|
|
298
|
+ </TABLE>
|
|
|
299
|
+ >]
|
|
|
300
|
+
|
|
|
301
|
+ recycle_core_models_ScrapListingItem [label=<
|
|
|
302
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
303
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
304
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
305
|
+ ScrapListingItem<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
306
|
+ </B></FONT></TD></TR>
|
|
|
307
|
+
|
|
|
308
|
+ </TABLE>
|
|
|
309
|
+ >]
|
|
|
310
|
+
|
|
|
311
|
+ recycle_core_models_ScrapBid [label=<
|
|
|
312
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
313
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
314
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
315
|
+ ScrapBid<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
316
|
+ </B></FONT></TD></TR>
|
|
|
317
|
+
|
|
|
318
|
+ </TABLE>
|
|
|
319
|
+ >]
|
|
|
320
|
+
|
|
|
321
|
+ recycle_core_models_ScrapAward [label=<
|
|
|
322
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
323
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
324
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
325
|
+ ScrapAward<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
326
|
+ </B></FONT></TD></TR>
|
|
|
327
|
+
|
|
|
328
|
+ </TABLE>
|
|
|
329
|
+ >]
|
|
|
330
|
+
|
|
|
331
|
+ recycle_core_models_ScrapListingInvite [label=<
|
|
|
332
|
+ <TABLE BGCOLOR="white" BORDER="1" CELLBORDER="0" CELLSPACING="0">
|
|
|
333
|
+ <TR><TD COLSPAN="2" CELLPADDING="5" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
334
|
+ <FONT FACE="Roboto" COLOR="white" POINT-SIZE="10"><B>
|
|
|
335
|
+ ScrapListingInvite<BR/><<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>>
|
|
|
336
|
+ </B></FONT></TD></TR>
|
|
|
337
|
+
|
|
|
338
|
+ </TABLE>
|
|
|
339
|
+ >]
|
|
|
340
|
+
|
|
|
341
|
+ }
|
|
|
342
|
+
|
|
|
343
|
+
|
|
|
344
|
+ // Relations
|
|
|
345
|
+
|
|
|
346
|
+ orgs_models_Organization -> orgs_models_TimestampedModel
|
|
|
347
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
348
|
+
|
|
|
349
|
+ orgs_models_OrganizationSite -> orgs_models_Organization
|
|
|
350
|
+ [label=" organization (sites)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
351
|
+ django_contrib_sites_models_Site [label=<
|
|
|
352
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
353
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
354
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">Site</FONT>
|
|
|
355
|
+ </TD></TR>
|
|
|
356
|
+ </TABLE>
|
|
|
357
|
+ >]
|
|
|
358
|
+ orgs_models_OrganizationSite -> django_contrib_sites_models_Site
|
|
|
359
|
+ [label=" site (organization_site)"] [arrowhead=none, arrowtail=none, dir=both];
|
|
|
360
|
+ django_contrib_auth_models_User [label=<
|
|
|
361
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
362
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
363
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
364
|
+ </TD></TR>
|
|
|
365
|
+ </TABLE>
|
|
|
366
|
+ >]
|
|
|
367
|
+ orgs_models_UserProfile -> django_contrib_auth_models_User
|
|
|
368
|
+ [label=" user (recycle_profile)"] [arrowhead=none, arrowtail=none, dir=both];
|
|
|
369
|
+
|
|
|
370
|
+ orgs_models_UserProfile -> orgs_models_Organization
|
|
|
371
|
+ [label=" organization (users)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
372
|
+
|
|
|
373
|
+ orgs_models_UserProfile -> orgs_models_TimestampedModel
|
|
|
374
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
375
|
+
|
|
|
376
|
+
|
|
|
377
|
+ recycle_core_models_MaterialCategory -> orgs_models_Organization
|
|
|
378
|
+ [label=" organization (material_categories)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
379
|
+
|
|
|
380
|
+ recycle_core_models_MaterialCategory -> recycle_core_models_TimestampedModel
|
|
|
381
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
382
|
+
|
|
|
383
|
+ recycle_core_models_ProvidedService -> orgs_models_Organization
|
|
|
384
|
+ [label=" organization (services)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
385
|
+
|
|
|
386
|
+ recycle_core_models_ProvidedService -> recycle_core_models_TimestampedModel
|
|
|
387
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
388
|
+
|
|
|
389
|
+ recycle_core_models_Material -> orgs_models_Organization
|
|
|
390
|
+ [label=" organization (materials)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
391
|
+
|
|
|
392
|
+ recycle_core_models_Material -> recycle_core_models_TimestampedModel
|
|
|
393
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
394
|
+
|
|
|
395
|
+ recycle_core_models_MaterialImage -> recycle_core_models_Material
|
|
|
396
|
+ [label=" material (images)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
397
|
+
|
|
|
398
|
+ recycle_core_models_MaterialImage -> recycle_core_models_TimestampedModel
|
|
|
399
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
400
|
+
|
|
|
401
|
+ recycle_core_models_PriceList -> orgs_models_Organization
|
|
|
402
|
+ [label=" organization (price_lists)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
403
|
+
|
|
|
404
|
+ recycle_core_models_PriceList -> recycle_core_models_TimestampedModel
|
|
|
405
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
406
|
+
|
|
|
407
|
+ recycle_core_models_PriceListItem -> recycle_core_models_PriceList
|
|
|
408
|
+ [label=" price_list (items)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
409
|
+
|
|
|
410
|
+ recycle_core_models_PriceListItem -> recycle_core_models_Material
|
|
|
411
|
+ [label=" material (price_items)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
412
|
+
|
|
|
413
|
+ recycle_core_models_PriceListItem -> recycle_core_models_TimestampedModel
|
|
|
414
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
415
|
+
|
|
|
416
|
+ recycle_core_models_Customer -> orgs_models_Organization
|
|
|
417
|
+ [label=" organization (customers)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
418
|
+
|
|
|
419
|
+ recycle_core_models_Customer -> recycle_core_models_PriceList
|
|
|
420
|
+ [label=" price_list (customers)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
421
|
+
|
|
|
422
|
+ recycle_core_models_Customer -> recycle_core_models_TimestampedModel
|
|
|
423
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
424
|
+
|
|
|
425
|
+ recycle_core_models_CustomerSite -> recycle_core_models_Customer
|
|
|
426
|
+ [label=" customer (sites)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
427
|
+
|
|
|
428
|
+ recycle_core_models_CustomerSite -> recycle_core_models_TimestampedModel
|
|
|
429
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
430
|
+
|
|
|
431
|
+ recycle_core_models_ServiceAgreement -> recycle_core_models_Customer
|
|
|
432
|
+ [label=" customer (agreements)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
433
|
+
|
|
|
434
|
+ recycle_core_models_ServiceAgreement -> recycle_core_models_CustomerSite
|
|
|
435
|
+ [label=" site (agreements)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
436
|
+
|
|
|
437
|
+ recycle_core_models_ServiceAgreement -> recycle_core_models_PriceList
|
|
|
438
|
+ [label=" price_list (agreements)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
439
|
+
|
|
|
440
|
+ recycle_core_models_ServiceAgreement -> recycle_core_models_TimestampedModel
|
|
|
441
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
442
|
+
|
|
|
443
|
+ recycle_core_models_PickupOrder -> orgs_models_Organization
|
|
|
444
|
+ [label=" organization (pickup_orders)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
445
|
+
|
|
|
446
|
+ recycle_core_models_PickupOrder -> recycle_core_models_Customer
|
|
|
447
|
+ [label=" customer (pickup_orders)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
448
|
+
|
|
|
449
|
+ recycle_core_models_PickupOrder -> recycle_core_models_CustomerSite
|
|
|
450
|
+ [label=" site (pickup_orders)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
451
|
+ django_contrib_auth_models_User [label=<
|
|
|
452
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
453
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
454
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
455
|
+ </TD></TR>
|
|
|
456
|
+ </TABLE>
|
|
|
457
|
+ >]
|
|
|
458
|
+ recycle_core_models_PickupOrder -> django_contrib_auth_models_User
|
|
|
459
|
+ [label=" assigned_driver (assigned_pickups)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
460
|
+ django_contrib_auth_models_User [label=<
|
|
|
461
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
462
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
463
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
464
|
+ </TD></TR>
|
|
|
465
|
+ </TABLE>
|
|
|
466
|
+ >]
|
|
|
467
|
+ recycle_core_models_PickupOrder -> django_contrib_auth_models_User
|
|
|
468
|
+ [label=" created_by (created_pickups)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
469
|
+
|
|
|
470
|
+ recycle_core_models_PickupOrder -> recycle_core_models_TimestampedModel
|
|
|
471
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
472
|
+
|
|
|
473
|
+ recycle_core_models_PickupItem -> recycle_core_models_PickupOrder
|
|
|
474
|
+ [label=" pickup (items)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
475
|
+
|
|
|
476
|
+ recycle_core_models_PickupItem -> recycle_core_models_Material
|
|
|
477
|
+ [label=" material (pickupitem)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
478
|
+
|
|
|
479
|
+ recycle_core_models_PickupItem -> recycle_core_models_TimestampedModel
|
|
|
480
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
481
|
+
|
|
|
482
|
+ recycle_core_models_WeighTicket -> recycle_core_models_PickupOrder
|
|
|
483
|
+ [label=" pickup (weigh_ticket)"] [arrowhead=none, arrowtail=none, dir=both];
|
|
|
484
|
+ django_contrib_auth_models_User [label=<
|
|
|
485
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
486
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
487
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
488
|
+ </TD></TR>
|
|
|
489
|
+ </TABLE>
|
|
|
490
|
+ >]
|
|
|
491
|
+ recycle_core_models_WeighTicket -> django_contrib_auth_models_User
|
|
|
492
|
+ [label=" recorded_by (weigh_tickets)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
493
|
+
|
|
|
494
|
+ recycle_core_models_WeighTicket -> recycle_core_models_TimestampedModel
|
|
|
495
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
496
|
+
|
|
|
497
|
+ recycle_core_models_WeighLine -> recycle_core_models_WeighTicket
|
|
|
498
|
+ [label=" ticket (lines)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
499
|
+
|
|
|
500
|
+ recycle_core_models_WeighLine -> recycle_core_models_Material
|
|
|
501
|
+ [label=" material (weighline)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
502
|
+
|
|
|
503
|
+ recycle_core_models_WeighLine -> recycle_core_models_TimestampedModel
|
|
|
504
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
505
|
+
|
|
|
506
|
+ recycle_core_models_Invoice -> orgs_models_Organization
|
|
|
507
|
+ [label=" organization (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
508
|
+
|
|
|
509
|
+ recycle_core_models_Invoice -> recycle_core_models_Customer
|
|
|
510
|
+ [label=" customer (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
511
|
+
|
|
|
512
|
+ recycle_core_models_Invoice -> recycle_core_models_PickupOrder
|
|
|
513
|
+ [label=" pickup (invoices)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
514
|
+
|
|
|
515
|
+ recycle_core_models_Invoice -> recycle_core_models_TimestampedModel
|
|
|
516
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
517
|
+
|
|
|
518
|
+ recycle_core_models_InvoiceLine -> recycle_core_models_Invoice
|
|
|
519
|
+ [label=" invoice (lines)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
520
|
+
|
|
|
521
|
+ recycle_core_models_InvoiceLine -> recycle_core_models_Material
|
|
|
522
|
+ [label=" material (invoiceline)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
523
|
+
|
|
|
524
|
+ recycle_core_models_InvoiceLine -> recycle_core_models_TimestampedModel
|
|
|
525
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
526
|
+
|
|
|
527
|
+ recycle_core_models_Payment -> recycle_core_models_Invoice
|
|
|
528
|
+ [label=" invoice (payments)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
529
|
+
|
|
|
530
|
+ recycle_core_models_Payment -> recycle_core_models_TimestampedModel
|
|
|
531
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
532
|
+
|
|
|
533
|
+ recycle_core_models_Payout -> orgs_models_Organization
|
|
|
534
|
+ [label=" organization (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
535
|
+
|
|
|
536
|
+ recycle_core_models_Payout -> recycle_core_models_Customer
|
|
|
537
|
+ [label=" customer (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
538
|
+
|
|
|
539
|
+ recycle_core_models_Payout -> recycle_core_models_PickupOrder
|
|
|
540
|
+ [label=" pickup (payouts)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
541
|
+
|
|
|
542
|
+ recycle_core_models_Payout -> recycle_core_models_TimestampedModel
|
|
|
543
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
544
|
+
|
|
|
545
|
+ recycle_core_models_Document -> orgs_models_Organization
|
|
|
546
|
+ [label=" organization (documents)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
547
|
+ django_contrib_contenttypes_models_ContentType [label=<
|
|
|
548
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
549
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
550
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">ContentType</FONT>
|
|
|
551
|
+ </TD></TR>
|
|
|
552
|
+ </TABLE>
|
|
|
553
|
+ >]
|
|
|
554
|
+ recycle_core_models_Document -> django_contrib_contenttypes_models_ContentType
|
|
|
555
|
+ [label=" content_type (document)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
556
|
+ django_contrib_auth_models_User [label=<
|
|
|
557
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
558
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
559
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
560
|
+ </TD></TR>
|
|
|
561
|
+ </TABLE>
|
|
|
562
|
+ >]
|
|
|
563
|
+ recycle_core_models_Document -> django_contrib_auth_models_User
|
|
|
564
|
+ [label=" uploaded_by (document)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
565
|
+
|
|
|
566
|
+ recycle_core_models_Document -> recycle_core_models_TimestampedModel
|
|
|
567
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
568
|
+
|
|
|
569
|
+ recycle_core_models_AuditLog -> orgs_models_Organization
|
|
|
570
|
+ [label=" organization (audit_logs)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
571
|
+ django_contrib_auth_models_User [label=<
|
|
|
572
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
573
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
574
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
575
|
+ </TD></TR>
|
|
|
576
|
+ </TABLE>
|
|
|
577
|
+ >]
|
|
|
578
|
+ recycle_core_models_AuditLog -> django_contrib_auth_models_User
|
|
|
579
|
+ [label=" user (auditlog)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
580
|
+ django_contrib_contenttypes_models_ContentType [label=<
|
|
|
581
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
582
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
583
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">ContentType</FONT>
|
|
|
584
|
+ </TD></TR>
|
|
|
585
|
+ </TABLE>
|
|
|
586
|
+ >]
|
|
|
587
|
+ recycle_core_models_AuditLog -> django_contrib_contenttypes_models_ContentType
|
|
|
588
|
+ [label=" content_type (auditlog)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
589
|
+
|
|
|
590
|
+ recycle_core_models_AuditLog -> recycle_core_models_TimestampedModel
|
|
|
591
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
592
|
+
|
|
|
593
|
+ recycle_core_models_ScrapListing -> orgs_models_Organization
|
|
|
594
|
+ [label=" organization (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
595
|
+
|
|
|
596
|
+ recycle_core_models_ScrapListing -> recycle_core_models_Customer
|
|
|
597
|
+ [label=" customer (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
598
|
+
|
|
|
599
|
+ recycle_core_models_ScrapListing -> recycle_core_models_CustomerSite
|
|
|
600
|
+ [label=" site (scrap_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
601
|
+ django_contrib_auth_models_User [label=<
|
|
|
602
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
603
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
604
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
605
|
+ </TD></TR>
|
|
|
606
|
+ </TABLE>
|
|
|
607
|
+ >]
|
|
|
608
|
+ recycle_core_models_ScrapListing -> django_contrib_auth_models_User
|
|
|
609
|
+ [label=" created_by (created_listings)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
610
|
+
|
|
|
611
|
+ recycle_core_models_ScrapListing -> recycle_core_models_TimestampedModel
|
|
|
612
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
613
|
+
|
|
|
614
|
+ recycle_core_models_ScrapListingItem -> recycle_core_models_ScrapListing
|
|
|
615
|
+ [label=" listing (items)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
616
|
+
|
|
|
617
|
+ recycle_core_models_ScrapListingItem -> recycle_core_models_Material
|
|
|
618
|
+ [label=" material (scraplistingitem)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
619
|
+
|
|
|
620
|
+ recycle_core_models_ScrapListingItem -> recycle_core_models_TimestampedModel
|
|
|
621
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
622
|
+
|
|
|
623
|
+ recycle_core_models_ScrapBid -> recycle_core_models_ScrapListing
|
|
|
624
|
+ [label=" listing (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
625
|
+
|
|
|
626
|
+ recycle_core_models_ScrapBid -> orgs_models_Organization
|
|
|
627
|
+ [label=" bidder_org (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
628
|
+ django_contrib_auth_models_User [label=<
|
|
|
629
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
630
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
631
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
632
|
+ </TD></TR>
|
|
|
633
|
+ </TABLE>
|
|
|
634
|
+ >]
|
|
|
635
|
+ recycle_core_models_ScrapBid -> django_contrib_auth_models_User
|
|
|
636
|
+ [label=" bidder_user (bids)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
637
|
+
|
|
|
638
|
+ recycle_core_models_ScrapBid -> recycle_core_models_TimestampedModel
|
|
|
639
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
640
|
+
|
|
|
641
|
+ recycle_core_models_ScrapAward -> recycle_core_models_ScrapListing
|
|
|
642
|
+ [label=" listing (award)"] [arrowhead=none, arrowtail=none, dir=both];
|
|
|
643
|
+
|
|
|
644
|
+ recycle_core_models_ScrapAward -> recycle_core_models_ScrapBid
|
|
|
645
|
+ [label=" winning_bid (awards)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
646
|
+
|
|
|
647
|
+ recycle_core_models_ScrapAward -> recycle_core_models_PickupOrder
|
|
|
648
|
+ [label=" pickup (awards)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
649
|
+
|
|
|
650
|
+ recycle_core_models_ScrapAward -> recycle_core_models_TimestampedModel
|
|
|
651
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
652
|
+
|
|
|
653
|
+ recycle_core_models_ScrapListingInvite -> recycle_core_models_ScrapListing
|
|
|
654
|
+ [label=" listing (invites)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
655
|
+
|
|
|
656
|
+ recycle_core_models_ScrapListingInvite -> orgs_models_Organization
|
|
|
657
|
+ [label=" invited_org (listing_invites)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
658
|
+ django_contrib_auth_models_User [label=<
|
|
|
659
|
+ <TABLE BGCOLOR="white" BORDER="0" CELLBORDER="0" CELLSPACING="0">
|
|
|
660
|
+ <TR><TD COLSPAN="2" CELLPADDING="4" ALIGN="CENTER" BGCOLOR="#1b563f">
|
|
|
661
|
+ <FONT FACE="Roboto" POINT-SIZE="12" COLOR="white">User</FONT>
|
|
|
662
|
+ </TD></TR>
|
|
|
663
|
+ </TABLE>
|
|
|
664
|
+ >]
|
|
|
665
|
+ recycle_core_models_ScrapListingInvite -> django_contrib_auth_models_User
|
|
|
666
|
+ [label=" invited_user (listing_invites)"] [arrowhead=none, arrowtail=dot, dir=both];
|
|
|
667
|
+
|
|
|
668
|
+ recycle_core_models_ScrapListingInvite -> recycle_core_models_TimestampedModel
|
|
|
669
|
+ [label=" abstract\ninheritance"] [arrowhead=empty, arrowtail=none, dir=both];
|
|
|
670
|
+
|
|
|
671
|
+
|
|
|
672
|
+}
|
|
|
@@ -4,12 +4,16 @@ from django import forms
|
|
4
|
4
|
|
|
5
|
5
|
|
|
6
|
6
|
class PickupRequestForm(forms.Form):
|
|
|
7
|
+ class MultiFileInput(forms.ClearableFileInput):
|
|
|
8
|
+ allow_multiple_selected = True
|
|
|
9
|
+
|
|
7
|
10
|
name = forms.CharField(max_length=255)
|
|
8
|
11
|
email = forms.EmailField(required=False)
|
|
9
|
12
|
phone = forms.CharField(max_length=64, required=False)
|
|
10
|
13
|
address = forms.CharField(widget=forms.Textarea)
|
|
11
|
14
|
preferred_at = forms.DateTimeField(required=False, widget=forms.DateTimeInput(attrs={"type": "datetime-local"}))
|
|
12
|
15
|
materials = forms.CharField(label="Materials/Notes", widget=forms.Textarea, required=False)
|
|
|
16
|
+ photos = forms.FileField(required=False, widget=MultiFileInput, help_text="Optional: upload photos of scrap")
|
|
13
|
17
|
|
|
14
|
18
|
|
|
15
|
19
|
class ContactForm(forms.Form):
|
|
|
@@ -18,4 +22,3 @@ class ContactForm(forms.Form):
|
|
18
|
22
|
phone = forms.CharField(max_length=64, required=False)
|
|
19
|
23
|
subject = forms.CharField(max_length=255, required=False)
|
|
20
|
24
|
message = forms.CharField(widget=forms.Textarea)
|
|
21
|
|
-
|
|
|
@@ -76,7 +76,7 @@
|
|
76
|
76
|
{# Pickup Request Section #}
|
|
77
|
77
|
<section id="pickup-request">
|
|
78
|
78
|
<h2 class="text-2xl font-semibold mb-3">Request a Pickup</h2>
|
|
79
|
|
- <form method="post" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
|
|
|
79
|
+ <form method="post" enctype="multipart/form-data" action="{% url 'public_frontend:pickup_request' %}" class="bg-white rounded-lg shadow-md p-4 grid gap-4">
|
|
80
|
80
|
{% csrf_token %}
|
|
81
|
81
|
<div>
|
|
82
|
82
|
<label class="block text-sm font-medium mb-1">Name</label>
|
|
|
@@ -101,6 +101,10 @@
|
|
101
|
101
|
<textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ pickup_form.materials.value|default:'' }}</textarea>
|
|
102
|
102
|
</div>
|
|
103
|
103
|
<div>
|
|
|
104
|
+ <label class="block text-sm font-medium mb-1">Photos (optional)</label>
|
|
|
105
|
+ <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
|
|
|
106
|
+ </div>
|
|
|
107
|
+ <div>
|
|
104
|
108
|
<button class="btn-primary" type="submit">Submit Request</button>
|
|
105
|
109
|
</div>
|
|
106
|
110
|
</form>
|
|
|
@@ -14,7 +14,7 @@
|
|
14
|
14
|
{% for m in materials %}
|
|
15
|
15
|
<tr class="border-t">
|
|
16
|
16
|
<td class="px-4 py-2">{{ m.name }}</td>
|
|
17
|
|
- <td class="px-4 py-2">{{ m.category.name }}</td>
|
|
|
17
|
+ <td class="px-4 py-2">{{ m.get_category_display }}</td>
|
|
18
|
18
|
<td class="px-4 py-2">{{ m.get_default_unit_display }}</td>
|
|
19
|
19
|
</tr>
|
|
20
|
20
|
{% empty %}
|
|
|
@@ -24,4 +24,3 @@
|
|
24
|
24
|
</table>
|
|
25
|
25
|
</div>
|
|
26
|
26
|
{% endblock %}
|
|
27
|
|
-
|
|
|
@@ -2,7 +2,7 @@
|
|
2
|
2
|
{% block title %}Request Pickup{% endblock %}
|
|
3
|
3
|
{% block content %}
|
|
4
|
4
|
<h1 class="text-xl font-semibold mb-4">Request a Pickup</h1>
|
|
5
|
|
-<form method="post" class="bg-white rounded shadow p-4 grid gap-4">
|
|
|
5
|
+<form method="post" enctype="multipart/form-data" class="bg-white rounded shadow p-4 grid gap-4">
|
|
6
|
6
|
{% csrf_token %}
|
|
7
|
7
|
<div>
|
|
8
|
8
|
<label class="block text-sm font-medium mb-1">Name</label>
|
|
|
@@ -35,9 +35,12 @@
|
|
35
|
35
|
<textarea name="materials" class="w-full border rounded px-3 py-2" rows="4">{{ form.materials.value|default:'' }}</textarea>
|
|
36
|
36
|
</div>
|
|
37
|
37
|
<div>
|
|
|
38
|
+ <label class="block text-sm font-medium mb-1">Photos (optional)</label>
|
|
|
39
|
+ <input type="file" name="photos" multiple accept="image/*" class="w-full border rounded px-3 py-2">
|
|
|
40
|
+ </div>
|
|
|
41
|
+ <div>
|
|
38
|
42
|
<button class="btn-primary" type="submit">Submit Request</button>
|
|
39
|
43
|
</div>
|
|
40
|
44
|
</form>
|
|
41
|
45
|
<style>.btn-primary{background:#1d4ed8;color:white;padding:.5rem .75rem;border-radius:.375rem}</style>
|
|
42
|
46
|
{% endblock %}
|
|
43
|
|
-
|
|
|
@@ -12,6 +12,10 @@ from cms.models import Post, PostCategory
|
|
12
|
12
|
|
|
13
|
13
|
from .forms import PickupRequestForm, ContactForm
|
|
14
|
14
|
from .models import Lead
|
|
|
15
|
+from recycle_core.controllers.pickup_request import (
|
|
|
16
|
+ PickupRequestController,
|
|
|
17
|
+ PickupRequestData,
|
|
|
18
|
+)
|
|
15
|
19
|
|
|
16
|
20
|
|
|
17
|
21
|
def home(request):
|
|
|
@@ -98,30 +102,29 @@ def service_detail(request, pk: int):
|
|
98
|
102
|
|
|
99
|
103
|
def pickup_request(request):
|
|
100
|
104
|
org = getattr(request, "org", None)
|
|
101
|
|
- form = PickupRequestForm(request.POST or None)
|
|
|
105
|
+ form = PickupRequestForm(request.POST or None, request.FILES or None)
|
|
102
|
106
|
if request.method == "POST":
|
|
103
|
107
|
if not org:
|
|
104
|
108
|
messages.error(request, "Organization context missing.")
|
|
105
|
109
|
return redirect("public_frontend:pickup_request")
|
|
106
|
110
|
if form.is_valid():
|
|
107
|
|
- # Store as a Lead for staff to process; PickupOrder requires customer/site
|
|
108
|
|
- message = (
|
|
109
|
|
- f"Pickup Request\n"
|
|
110
|
|
- f"Address: {form.cleaned_data.get('address')}\n"
|
|
111
|
|
- f"Preferred: {form.cleaned_data.get('preferred_at') or ''}\n"
|
|
112
|
|
- f"Materials: {form.cleaned_data.get('materials') or ''}"
|
|
113
|
|
- )
|
|
114
|
|
- Lead.objects.create(
|
|
|
111
|
+ ctrl = PickupRequestController()
|
|
|
112
|
+ data = PickupRequestData(
|
|
115
|
113
|
organization=org,
|
|
116
|
|
- name=form.cleaned_data.get('name'),
|
|
117
|
|
- email=form.cleaned_data.get('email',''),
|
|
118
|
|
- phone=form.cleaned_data.get('phone',''),
|
|
119
|
|
- subject='Pickup Request',
|
|
120
|
|
- message=message,
|
|
121
|
|
- source='pickup_request',
|
|
|
114
|
+ name=form.cleaned_data.get("name"),
|
|
|
115
|
+ email=form.cleaned_data.get("email", ""),
|
|
|
116
|
+ phone=form.cleaned_data.get("phone", ""),
|
|
|
117
|
+ address=form.cleaned_data.get("address", ""),
|
|
|
118
|
+ materials=form.cleaned_data.get("materials", ""),
|
|
|
119
|
+ preferred_at=form.cleaned_data.get("preferred_at"),
|
|
|
120
|
+ files=request.FILES.getlist("photos") if hasattr(request, "FILES") and "photos" in request.FILES else [],
|
|
122
|
121
|
)
|
|
123
|
|
- messages.success(request, "Thanks! Your pickup request was submitted.")
|
|
124
|
|
- return redirect("public_frontend:home")
|
|
|
122
|
+ result = ctrl.submit(data)
|
|
|
123
|
+ if result.ok:
|
|
|
124
|
+ messages.success(request, "Thanks! Your pickup request was submitted.")
|
|
|
125
|
+ return redirect("public_frontend:home")
|
|
|
126
|
+ else:
|
|
|
127
|
+ messages.error(request, result.error or "Unable to submit your request.")
|
|
125
|
128
|
messages.error(request, "Please correct the errors below.")
|
|
126
|
129
|
return render(request, "public_frontend/pickup_request.html", {"form": form, "org": org})
|
|
127
|
130
|
|
|
|
@@ -39,9 +39,13 @@ class MaterialCategoryAdmin(OrgScopedAdmin):
|
|
39
|
39
|
|
|
40
|
40
|
@admin.register(models.Material)
|
|
41
|
41
|
class MaterialAdmin(OrgScopedAdmin):
|
|
42
|
|
- list_display = ("name", "code", "category", "organization", "default_unit")
|
|
|
42
|
+ list_display = ("name", "code", "get_category_display", "organization", "default_unit")
|
|
43
|
43
|
list_filter = ("default_unit", "category")
|
|
44
|
44
|
search_fields = ("name", "code")
|
|
|
45
|
+ class MaterialImageInline(admin.TabularInline):
|
|
|
46
|
+ model = models.MaterialImage
|
|
|
47
|
+ extra = 1
|
|
|
48
|
+ inlines = [MaterialImageInline]
|
|
45
|
49
|
|
|
46
|
50
|
|
|
47
|
51
|
@admin.register(models.PriceList)
|
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+from __future__ import annotations
|
|
|
2
|
+
|
|
|
3
|
+from dataclasses import dataclass
|
|
|
4
|
+from typing import Iterable, List, Optional
|
|
|
5
|
+from django.contrib.contenttypes.models import ContentType
|
|
|
6
|
+from django.utils import timezone
|
|
|
7
|
+
|
|
|
8
|
+from orgs.models import Organization
|
|
|
9
|
+from recycle_core.models import Document
|
|
|
10
|
+
|
|
|
11
|
+
|
|
|
12
|
+@dataclass
|
|
|
13
|
+class PickupRequestData:
|
|
|
14
|
+ organization: Organization
|
|
|
15
|
+ name: str
|
|
|
16
|
+ email: str = ""
|
|
|
17
|
+ phone: str = ""
|
|
|
18
|
+ address: str = ""
|
|
|
19
|
+ materials: str = ""
|
|
|
20
|
+ preferred_at: Optional[timezone.datetime] = None
|
|
|
21
|
+ files: Optional[Iterable] = None # iterable of UploadedFile
|
|
|
22
|
+
|
|
|
23
|
+
|
|
|
24
|
+@dataclass
|
|
|
25
|
+class PickupRequestResult:
|
|
|
26
|
+ ok: bool
|
|
|
27
|
+ lead_id: Optional[int] = None
|
|
|
28
|
+ error: Optional[str] = None
|
|
|
29
|
+ document_ids: List[int] = None
|
|
|
30
|
+
|
|
|
31
|
+
|
|
|
32
|
+class PickupRequestController:
|
|
|
33
|
+ """Application layer controller for the fast-path pickup request.
|
|
|
34
|
+
|
|
|
35
|
+ - Creates a Lead scoped to an Organization
|
|
|
36
|
+ - Stores any uploaded photos/documents as Document records attached to the Lead
|
|
|
37
|
+ """
|
|
|
38
|
+
|
|
|
39
|
+ def submit(self, data: PickupRequestData) -> PickupRequestResult:
|
|
|
40
|
+ from public_frontend.models import Lead # import locally to avoid circular imports
|
|
|
41
|
+
|
|
|
42
|
+ try:
|
|
|
43
|
+ # Prepare message body for staff
|
|
|
44
|
+ message = (
|
|
|
45
|
+ f"Pickup Request\n"
|
|
|
46
|
+ f"Address: {data.address}\n"
|
|
|
47
|
+ f"Preferred: {data.preferred_at or ''}\n"
|
|
|
48
|
+ f"Materials: {data.materials or ''}"
|
|
|
49
|
+ )
|
|
|
50
|
+
|
|
|
51
|
+ lead = Lead.objects.create(
|
|
|
52
|
+ organization=data.organization,
|
|
|
53
|
+ name=data.name,
|
|
|
54
|
+ email=data.email or "",
|
|
|
55
|
+ phone=data.phone or "",
|
|
|
56
|
+ subject="Pickup Request",
|
|
|
57
|
+ message=message,
|
|
|
58
|
+ source="pickup_request",
|
|
|
59
|
+ )
|
|
|
60
|
+
|
|
|
61
|
+ # Attach uploaded files as Documents linked to the Lead
|
|
|
62
|
+ doc_ids: List[int] = []
|
|
|
63
|
+ if data.files:
|
|
|
64
|
+ ct = ContentType.objects.get_for_model(Lead)
|
|
|
65
|
+ for idx, f in enumerate(data.files):
|
|
|
66
|
+ doc = Document.objects.create(
|
|
|
67
|
+ organization=data.organization,
|
|
|
68
|
+ file=f,
|
|
|
69
|
+ kind="pickup_request",
|
|
|
70
|
+ content_type=ct,
|
|
|
71
|
+ object_id=lead.id,
|
|
|
72
|
+ uploaded_by=None,
|
|
|
73
|
+ )
|
|
|
74
|
+ doc_ids.append(doc.id)
|
|
|
75
|
+
|
|
|
76
|
+ return PickupRequestResult(ok=True, lead_id=lead.id, document_ids=doc_ids)
|
|
|
77
|
+
|
|
|
78
|
+ except Exception as e:
|
|
|
79
|
+ return PickupRequestResult(ok=False, error=str(e), document_ids=[])
|
|
|
80
|
+
|
|
|
@@ -1,4 +1,5 @@
|
|
1
|
1
|
from django import forms
|
|
|
2
|
+from django.core.exceptions import ValidationError
|
|
2
|
3
|
from django.contrib.auth import get_user_model
|
|
3
|
4
|
from decimal import Decimal
|
|
4
|
5
|
from django.utils import timezone
|
|
|
@@ -7,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
|
|
7
|
8
|
from .models import (
|
|
8
|
9
|
MaterialCategory,
|
|
9
|
10
|
Material,
|
|
|
11
|
+ MaterialImage,
|
|
10
|
12
|
ProvidedService,
|
|
11
|
13
|
Customer,
|
|
12
|
14
|
CustomerSite,
|
|
|
@@ -21,10 +23,58 @@ class MaterialCategoryForm(forms.ModelForm):
|
|
21
|
23
|
fields = ["organization", "name"]
|
|
22
|
24
|
|
|
23
|
25
|
|
|
|
26
|
+class MultiFileInput(forms.ClearableFileInput):
|
|
|
27
|
+ allow_multiple_selected = True
|
|
|
28
|
+
|
|
|
29
|
+
|
|
|
30
|
+class MultiImageField(forms.Field):
|
|
|
31
|
+ widget = MultiFileInput
|
|
|
32
|
+
|
|
|
33
|
+ def __init__(self, *args, **kwargs):
|
|
|
34
|
+ kwargs.setdefault("required", False)
|
|
|
35
|
+ super().__init__(*args, **kwargs)
|
|
|
36
|
+
|
|
|
37
|
+ def to_python(self, data):
|
|
|
38
|
+ return data
|
|
|
39
|
+
|
|
|
40
|
+ def validate(self, value):
|
|
|
41
|
+ # Basic required check; skip per-file validation here
|
|
|
42
|
+ if self.required and not value:
|
|
|
43
|
+ raise ValidationError("This field is required.")
|
|
|
44
|
+
|
|
|
45
|
+
|
|
24
|
46
|
class MaterialForm(forms.ModelForm):
|
|
|
47
|
+ images = MultiImageField(help_text="Upload one or more sample images (optional)")
|
|
|
48
|
+
|
|
25
|
49
|
class Meta:
|
|
26
|
50
|
model = Material
|
|
27
|
|
- fields = ["organization", "category", "name", "code", "default_unit"]
|
|
|
51
|
+ fields = ["organization", "category", "name", "code", "default_unit", "images"]
|
|
|
52
|
+
|
|
|
53
|
+ def save(self, commit=True):
|
|
|
54
|
+ instance = super().save(commit=commit)
|
|
|
55
|
+ files = self.files.getlist("images") if hasattr(self, "files") else []
|
|
|
56
|
+ if commit and files:
|
|
|
57
|
+ # Instance has a PK; we can create images now
|
|
|
58
|
+ order_start = instance.images.count()
|
|
|
59
|
+ for i, f in enumerate(files):
|
|
|
60
|
+ MaterialImage.objects.create(material=instance, image=f, display_order=order_start + i)
|
|
|
61
|
+ else:
|
|
|
62
|
+ # Defer image saving until caller completes save
|
|
|
63
|
+ self._pending_images = files
|
|
|
64
|
+ return instance
|
|
|
65
|
+
|
|
|
66
|
+ def save_images(self, instance: Material | None = None):
|
|
|
67
|
+ """Persist any pending images after the Material has been saved."""
|
|
|
68
|
+ if not hasattr(self, "_pending_images"):
|
|
|
69
|
+ return
|
|
|
70
|
+ target = instance or getattr(self, "instance", None)
|
|
|
71
|
+ if not target or not getattr(target, "pk", None):
|
|
|
72
|
+ return
|
|
|
73
|
+ order_start = target.images.count()
|
|
|
74
|
+ for i, f in enumerate(self._pending_images or []):
|
|
|
75
|
+ MaterialImage.objects.create(material=target, image=f, display_order=order_start + i)
|
|
|
76
|
+ # Clear pending list
|
|
|
77
|
+ self._pending_images = []
|
|
28
|
78
|
|
|
29
|
79
|
|
|
30
|
80
|
class CustomerForm(forms.ModelForm):
|
|
|
@@ -35,6 +35,7 @@ class Command(BaseCommand):
|
|
35
|
35
|
def add_arguments(self, parser):
|
|
36
|
36
|
parser.add_argument("--org", default="DEMO", help="Organization code/id/name to seed (default: DEMO)")
|
|
37
|
37
|
parser.add_argument("--bidder-org", dest="bidder_org", default="REC1", help="Bidder org code/id/name (default: REC1)")
|
|
|
38
|
+ parser.add_argument("--reset", action="store_true", help="Delete existing data for the target orgs before seeding")
|
|
38
|
39
|
|
|
39
|
40
|
def handle(self, *args, **options):
|
|
40
|
41
|
now = timezone.now()
|
|
|
@@ -59,6 +60,70 @@ class Command(BaseCommand):
|
|
59
|
60
|
org = _resolve_org(org_ident, default_name=("Ecoloop " + str(org_ident)))
|
|
60
|
61
|
bidder_org = _resolve_org(bidder_ident, default_name="Recycler Co.")
|
|
61
|
62
|
|
|
|
63
|
+ # Optionally reset existing demo data (scoped to the selected orgs)
|
|
|
64
|
+ if options.get("reset"):
|
|
|
65
|
+ from recycle_core.models import (
|
|
|
66
|
+ ScrapAward,
|
|
|
67
|
+ ScrapBid,
|
|
|
68
|
+ ScrapListingInvite,
|
|
|
69
|
+ ScrapListingItem,
|
|
|
70
|
+ ScrapListing,
|
|
|
71
|
+ WeighLine,
|
|
|
72
|
+ WeighTicket,
|
|
|
73
|
+ PickupItem,
|
|
|
74
|
+ PickupOrder,
|
|
|
75
|
+ InvoiceLine,
|
|
|
76
|
+ Invoice,
|
|
|
77
|
+ Payment,
|
|
|
78
|
+ Payout,
|
|
|
79
|
+ ServiceAgreement,
|
|
|
80
|
+ CustomerSite,
|
|
|
81
|
+ Customer,
|
|
|
82
|
+ PriceListItem,
|
|
|
83
|
+ PriceList,
|
|
|
84
|
+ Material,
|
|
|
85
|
+ MaterialCategory,
|
|
|
86
|
+ ProvidedService,
|
|
|
87
|
+ )
|
|
|
88
|
+
|
|
|
89
|
+ def _wipe_for(o: Organization):
|
|
|
90
|
+ # Marketplace
|
|
|
91
|
+ ScrapAward.objects.filter(listing__organization=o).delete()
|
|
|
92
|
+ ScrapBid.objects.filter(listing__organization=o).delete()
|
|
|
93
|
+ ScrapListingInvite.objects.filter(listing__organization=o).delete()
|
|
|
94
|
+ ScrapListingItem.objects.filter(listing__organization=o).delete()
|
|
|
95
|
+ ScrapListing.objects.filter(organization=o).delete()
|
|
|
96
|
+
|
|
|
97
|
+ # Operations
|
|
|
98
|
+ WeighLine.objects.filter(ticket__pickup__organization=o).delete()
|
|
|
99
|
+ WeighTicket.objects.filter(pickup__organization=o).delete()
|
|
|
100
|
+ PickupItem.objects.filter(pickup__organization=o).delete()
|
|
|
101
|
+ PickupOrder.objects.filter(organization=o).delete()
|
|
|
102
|
+
|
|
|
103
|
+ # Billing
|
|
|
104
|
+ InvoiceLine.objects.filter(invoice__organization=o).delete()
|
|
|
105
|
+ Payment.objects.filter(invoice__organization=o).delete()
|
|
|
106
|
+ Invoice.objects.filter(organization=o).delete()
|
|
|
107
|
+ Payout.objects.filter(organization=o).delete()
|
|
|
108
|
+
|
|
|
109
|
+ # Customers and agreements
|
|
|
110
|
+ ServiceAgreement.objects.filter(customer__organization=o).delete()
|
|
|
111
|
+ CustomerSite.objects.filter(customer__organization=o).delete()
|
|
|
112
|
+ Customer.objects.filter(organization=o).delete()
|
|
|
113
|
+
|
|
|
114
|
+ # Pricing
|
|
|
115
|
+ PriceListItem.objects.filter(price_list__organization=o).delete()
|
|
|
116
|
+ PriceList.objects.filter(organization=o).delete()
|
|
|
117
|
+
|
|
|
118
|
+ # Inventory and services
|
|
|
119
|
+ Material.objects.filter(organization=o).delete()
|
|
|
120
|
+ ProvidedService.objects.filter(organization=o).delete()
|
|
|
121
|
+ MaterialCategory.objects.filter(organization=o).delete()
|
|
|
122
|
+
|
|
|
123
|
+ _wipe_for(org)
|
|
|
124
|
+ _wipe_for(bidder_org)
|
|
|
125
|
+ self.stdout.write(self.style.WARNING("Existing data removed for selected orgs (reset)."))
|
|
|
126
|
+
|
|
62
|
127
|
# Users
|
|
63
|
128
|
manager = User.objects.filter(username="manager").first()
|
|
64
|
129
|
if not manager:
|
|
|
@@ -80,10 +145,10 @@ class Command(BaseCommand):
|
|
80
|
145
|
metals, _ = MaterialCategory.objects.get_or_create(organization=org, name="Metals")
|
|
81
|
146
|
paper, _ = MaterialCategory.objects.get_or_create(organization=org, name="Paper")
|
|
82
|
147
|
|
|
83
|
|
- pet, _ = Material.objects.get_or_create(organization=org, category=plastics, name="PET", defaults={"default_unit": Material.UNIT_KG})
|
|
84
|
|
- hdpe, _ = Material.objects.get_or_create(organization=org, category=plastics, name="HDPE", defaults={"default_unit": Material.UNIT_KG})
|
|
85
|
|
- can, _ = Material.objects.get_or_create(organization=org, category=metals, name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
|
|
86
|
|
- cardboard, _ = Material.objects.get_or_create(organization=org, category=paper, name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
|
|
|
148
|
+ pet, _ = Material.objects.get_or_create(organization=org, category="Plastics", name="PET", defaults={"default_unit": Material.UNIT_KG})
|
|
|
149
|
+ hdpe, _ = Material.objects.get_or_create(organization=org, category="Plastics", name="HDPE", defaults={"default_unit": Material.UNIT_KG})
|
|
|
150
|
+ can, _ = Material.objects.get_or_create(organization=org, category="Metals", name="Aluminum Can", defaults={"default_unit": Material.UNIT_KG})
|
|
|
151
|
+ cardboard, _ = Material.objects.get_or_create(organization=org, category="Paper", name="Cardboard", defaults={"default_unit": Material.UNIT_KG})
|
|
87
|
152
|
|
|
88
|
153
|
# Price list
|
|
89
|
154
|
pl, _ = PriceList.objects.get_or_create(
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+# Generated by Django 4.2.24 on 2025-09-22 09:17
|
|
|
2
|
+
|
|
|
3
|
+from django.db import migrations, models
|
|
|
4
|
+
|
|
|
5
|
+
|
|
|
6
|
+class Migration(migrations.Migration):
|
|
|
7
|
+
|
|
|
8
|
+ dependencies = [
|
|
|
9
|
+ ('recycle_core', '0005_providedservice_is_enabled'),
|
|
|
10
|
+ ]
|
|
|
11
|
+
|
|
|
12
|
+ operations = [
|
|
|
13
|
+ migrations.AlterField(
|
|
|
14
|
+ model_name='materialcategory',
|
|
|
15
|
+ name='name',
|
|
|
16
|
+ field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=255),
|
|
|
17
|
+ ),
|
|
|
18
|
+ ]
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+# Generated by Django 4.2.24 on 2025-09-22 09:23
|
|
|
2
|
+
|
|
|
3
|
+from django.db import migrations, models
|
|
|
4
|
+
|
|
|
5
|
+
|
|
|
6
|
+class Migration(migrations.Migration):
|
|
|
7
|
+
|
|
|
8
|
+ dependencies = [
|
|
|
9
|
+ ('recycle_core', '0006_alter_materialcategory_name'),
|
|
|
10
|
+ ]
|
|
|
11
|
+
|
|
|
12
|
+ operations = [
|
|
|
13
|
+ migrations.AlterField(
|
|
|
14
|
+ model_name='material',
|
|
|
15
|
+ name='category',
|
|
|
16
|
+ field=models.CharField(choices=[('Plastics', 'Plastics'), ('Metals', 'Metals'), ('Paper', 'Paper'), ('Glass', 'Glass'), ('Electronics', 'Electronics'), ('Wood', 'Wood'), ('Rubber', 'Rubber'), ('Textiles', 'Textiles'), ('Organic', 'Organic'), ('Mixed', 'Mixed')], max_length=64),
|
|
|
17
|
+ ),
|
|
|
18
|
+ ]
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+# Generated by Django 4.2.24 on 2025-09-22 09:28
|
|
|
2
|
+
|
|
|
3
|
+from django.db import migrations, models
|
|
|
4
|
+import django.db.models.deletion
|
|
|
5
|
+
|
|
|
6
|
+
|
|
|
7
|
+class Migration(migrations.Migration):
|
|
|
8
|
+
|
|
|
9
|
+ dependencies = [
|
|
|
10
|
+ ('recycle_core', '0007_alter_material_category'),
|
|
|
11
|
+ ]
|
|
|
12
|
+
|
|
|
13
|
+ operations = [
|
|
|
14
|
+ migrations.CreateModel(
|
|
|
15
|
+ name='MaterialImage',
|
|
|
16
|
+ fields=[
|
|
|
17
|
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
|
18
|
+ ('created_at', models.DateTimeField(auto_now_add=True)),
|
|
|
19
|
+ ('updated_at', models.DateTimeField(auto_now=True)),
|
|
|
20
|
+ ('image', models.ImageField(upload_to='materials/%Y/%m/')),
|
|
|
21
|
+ ('caption', models.CharField(blank=True, max_length=255)),
|
|
|
22
|
+ ('display_order', models.PositiveIntegerField(default=0)),
|
|
|
23
|
+ ('material', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='images', to='recycle_core.material')),
|
|
|
24
|
+ ],
|
|
|
25
|
+ options={
|
|
|
26
|
+ 'ordering': ['display_order', 'id'],
|
|
|
27
|
+ },
|
|
|
28
|
+ ),
|
|
|
29
|
+ ]
|
|
|
@@ -27,7 +27,20 @@ class TimestampedModel(models.Model):
|
|
27
|
27
|
|
|
28
|
28
|
class MaterialCategory(TimestampedModel):
|
|
29
|
29
|
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="material_categories")
|
|
30
|
|
- name = models.CharField(max_length=255)
|
|
|
30
|
+ # Limit to a curated, preset set of category names
|
|
|
31
|
+ CATEGORY_CHOICES = (
|
|
|
32
|
+ ("Plastics", "Plastics"),
|
|
|
33
|
+ ("Metals", "Metals"),
|
|
|
34
|
+ ("Paper", "Paper"),
|
|
|
35
|
+ ("Glass", "Glass"),
|
|
|
36
|
+ ("Electronics", "Electronics"),
|
|
|
37
|
+ ("Wood", "Wood"),
|
|
|
38
|
+ ("Rubber", "Rubber"),
|
|
|
39
|
+ ("Textiles", "Textiles"),
|
|
|
40
|
+ ("Organic", "Organic"),
|
|
|
41
|
+ ("Mixed", "Mixed"),
|
|
|
42
|
+ )
|
|
|
43
|
+ name = models.CharField(max_length=255, choices=CATEGORY_CHOICES)
|
|
31
|
44
|
|
|
32
|
45
|
class Meta:
|
|
33
|
46
|
unique_together = ("organization", "name")
|
|
|
@@ -81,7 +94,9 @@ class ProvidedService(TimestampedModel):
|
|
81
|
94
|
|
|
82
|
95
|
class Material(TimestampedModel):
|
|
83
|
96
|
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="materials")
|
|
84
|
|
- category = models.ForeignKey(MaterialCategory, on_delete=models.PROTECT, related_name="materials")
|
|
|
97
|
+ # Preset category choices (no FK)
|
|
|
98
|
+ CATEGORY_CHOICES = MaterialCategory.CATEGORY_CHOICES
|
|
|
99
|
+ category = models.CharField(max_length=64, choices=CATEGORY_CHOICES)
|
|
85
|
100
|
name = models.CharField(max_length=255)
|
|
86
|
101
|
code = models.CharField(max_length=64, blank=True)
|
|
87
|
102
|
# unit choices keep MVP simple; conversions out of scope for now
|
|
|
@@ -102,6 +117,19 @@ class Material(TimestampedModel):
|
|
102
|
117
|
return self.name
|
|
103
|
118
|
|
|
104
|
119
|
|
|
|
120
|
+class MaterialImage(TimestampedModel):
|
|
|
121
|
+ material = models.ForeignKey(Material, on_delete=models.CASCADE, related_name="images")
|
|
|
122
|
+ image = models.ImageField(upload_to="materials/%Y/%m/")
|
|
|
123
|
+ caption = models.CharField(max_length=255, blank=True)
|
|
|
124
|
+ display_order = models.PositiveIntegerField(default=0)
|
|
|
125
|
+
|
|
|
126
|
+ class Meta:
|
|
|
127
|
+ ordering = ["display_order", "id"]
|
|
|
128
|
+
|
|
|
129
|
+ def __str__(self) -> str:
|
|
|
130
|
+ return self.caption or f"MaterialImage #{self.id}"
|
|
|
131
|
+
|
|
|
132
|
+
|
|
105
|
133
|
class PriceList(TimestampedModel):
|
|
106
|
134
|
organization = models.ForeignKey(Organization, on_delete=models.CASCADE, related_name="price_lists")
|
|
107
|
135
|
name = models.CharField(max_length=255)
|
|
|
@@ -5,7 +5,28 @@
|
|
5
|
5
|
{% render_breadcrumbs breadcrumbs %}
|
|
6
|
6
|
<div class="bg-white rounded shadow p-4">
|
|
7
|
7
|
<h1 class="text-xl font-semibold mb-4">Edit Material</h1>
|
|
8
|
|
- <form method="post">
|
|
|
8
|
+
|
|
|
9
|
+ {% if item %}
|
|
|
10
|
+ {% with imgs=item.images.all %}
|
|
|
11
|
+ {% if imgs %}
|
|
|
12
|
+ <div class="mb-4">
|
|
|
13
|
+ <h2 class="text-lg font-medium mb-2">Existing Images</h2>
|
|
|
14
|
+ <div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
|
|
15
|
+ {% for im in imgs %}
|
|
|
16
|
+ <div class="border rounded p-2 bg-white flex flex-col items-center gap-2">
|
|
|
17
|
+ <img src="{{ im.image.url }}" alt="{{ im.caption|default:'Material image' }}" class="w-28 h-28 object-cover rounded" />
|
|
|
18
|
+ {% if im.caption %}
|
|
|
19
|
+ <div class="text-xs text-gray-600 text-center">{{ im.caption }}</div>
|
|
|
20
|
+ {% endif %}
|
|
|
21
|
+ </div>
|
|
|
22
|
+ {% endfor %}
|
|
|
23
|
+ </div>
|
|
|
24
|
+ </div>
|
|
|
25
|
+ {% endif %}
|
|
|
26
|
+ {% endwith %}
|
|
|
27
|
+ {% endif %}
|
|
|
28
|
+
|
|
|
29
|
+ <form method="post" enctype="multipart/form-data">
|
|
9
|
30
|
{% csrf_token %}
|
|
10
|
31
|
{{ form|crispy }}
|
|
11
|
32
|
<div class="mt-3 flex gap-2">
|
|
|
@@ -54,7 +54,7 @@
|
|
54
|
54
|
{% for m in materials %}
|
|
55
|
55
|
<tr>
|
|
56
|
56
|
<td class="px-4 py-2">{{ m.organization.name }}</td>
|
|
57
|
|
- <td class="px-4 py-2">{{ m.category.name }}</td>
|
|
|
57
|
+ <td class="px-4 py-2">{{ m.get_category_display }}</td>
|
|
58
|
58
|
<td class="px-4 py-2">{{ m.name }}</td>
|
|
59
|
59
|
<td class="px-4 py-2">{{ m.code }}</td>
|
|
60
|
60
|
<td class="px-4 py-2">{{ m.get_default_unit_display }}</td>
|
|
|
@@ -1,3 +1,66 @@
|
|
1
|
1
|
from django.test import TestCase
|
|
|
2
|
+from django.core.files.uploadedfile import SimpleUploadedFile
|
|
2
|
3
|
|
|
3
|
|
-# Create your tests here.
|
|
|
4
|
+from orgs.models import Organization
|
|
|
5
|
+from public_frontend.models import Lead
|
|
|
6
|
+from .models import Document
|
|
|
7
|
+from .controllers.pickup_request import PickupRequestController, PickupRequestData
|
|
|
8
|
+
|
|
|
9
|
+
|
|
|
10
|
+class PickupRequestControllerTests(TestCase):
|
|
|
11
|
+ def setUp(self) -> None:
|
|
|
12
|
+ self.org = Organization.objects.create(name="Test Org", code="TEST")
|
|
|
13
|
+ self.ctrl = PickupRequestController()
|
|
|
14
|
+
|
|
|
15
|
+ def test_submit_without_files_creates_lead_only(self):
|
|
|
16
|
+ data = PickupRequestData(
|
|
|
17
|
+ organization=self.org,
|
|
|
18
|
+ name="Alice",
|
|
|
19
|
+ email="alice@example.com",
|
|
|
20
|
+ phone="+1 555 0000",
|
|
|
21
|
+ address="123 Road",
|
|
|
22
|
+ materials="PET bottles",
|
|
|
23
|
+ preferred_at=None,
|
|
|
24
|
+ files=[],
|
|
|
25
|
+ )
|
|
|
26
|
+
|
|
|
27
|
+ result = self.ctrl.submit(data)
|
|
|
28
|
+ self.assertTrue(result.ok)
|
|
|
29
|
+ self.assertIsNotNone(result.lead_id)
|
|
|
30
|
+ self.assertEqual(result.document_ids, [])
|
|
|
31
|
+
|
|
|
32
|
+ lead = Lead.objects.get(pk=result.lead_id)
|
|
|
33
|
+ self.assertEqual(lead.organization, self.org)
|
|
|
34
|
+ self.assertEqual(lead.name, "Alice")
|
|
|
35
|
+ self.assertEqual(lead.subject, "Pickup Request")
|
|
|
36
|
+ self.assertEqual(lead.source, "pickup_request")
|
|
|
37
|
+
|
|
|
38
|
+ self.assertEqual(Document.objects.filter(object_id=lead.id).count(), 0)
|
|
|
39
|
+
|
|
|
40
|
+ def test_submit_with_files_creates_documents(self):
|
|
|
41
|
+ file1 = SimpleUploadedFile("photo1.jpg", b"fakejpegdata1", content_type="image/jpeg")
|
|
|
42
|
+ file2 = SimpleUploadedFile("photo2.jpg", b"fakejpegdata2", content_type="image/jpeg")
|
|
|
43
|
+
|
|
|
44
|
+ data = PickupRequestData(
|
|
|
45
|
+ organization=self.org,
|
|
|
46
|
+ name="Bob",
|
|
|
47
|
+ email="bob@example.com",
|
|
|
48
|
+ phone="+1 555 1111",
|
|
|
49
|
+ address="456 Avenue",
|
|
|
50
|
+ materials="Aluminum cans",
|
|
|
51
|
+ preferred_at=None,
|
|
|
52
|
+ files=[file1, file2],
|
|
|
53
|
+ )
|
|
|
54
|
+
|
|
|
55
|
+ result = self.ctrl.submit(data)
|
|
|
56
|
+ self.assertTrue(result.ok)
|
|
|
57
|
+ self.assertIsNotNone(result.lead_id)
|
|
|
58
|
+ self.assertEqual(len(result.document_ids), 2)
|
|
|
59
|
+
|
|
|
60
|
+ lead = Lead.objects.get(pk=result.lead_id)
|
|
|
61
|
+ docs = Document.objects.filter(object_id=lead.id).order_by("id")
|
|
|
62
|
+ self.assertEqual(docs.count(), 2)
|
|
|
63
|
+ for d in docs:
|
|
|
64
|
+ self.assertEqual(d.organization, self.org)
|
|
|
65
|
+ self.assertEqual(d.kind, "pickup_request")
|
|
|
66
|
+ self.assertEqual(d.content_object, lead)
|
|
|
@@ -72,7 +72,7 @@ def owner_required(view_func):
|
|
72
|
72
|
@breadcrumbs(label="Materials", name="re_materials")
|
|
73
|
73
|
def materials_list(request):
|
|
74
|
74
|
# Create forms
|
|
75
|
|
- mat_form = MaterialForm(request.POST or None)
|
|
|
75
|
+ mat_form = MaterialForm(request.POST or None, request.FILES or None)
|
|
76
|
76
|
cat_form = MaterialCategoryForm(request.POST or None)
|
|
77
|
77
|
|
|
78
|
78
|
# Restrict organization choices in forms to current org
|
|
|
@@ -91,6 +91,11 @@ def materials_list(request):
|
|
91
|
91
|
if getattr(request, "org", None) is not None:
|
|
92
|
92
|
obj.organization = request.org
|
|
93
|
93
|
obj.save()
|
|
|
94
|
+ # Save any uploaded images deferred by the form
|
|
|
95
|
+ try:
|
|
|
96
|
+ mat_form.save_images(instance=obj)
|
|
|
97
|
+ except Exception:
|
|
|
98
|
+ pass
|
|
94
|
99
|
messages.success(request, "Material created.")
|
|
95
|
100
|
return redirect("recycle_core:materials_list")
|
|
96
|
101
|
else:
|
|
|
@@ -109,14 +114,14 @@ def materials_list(request):
|
|
109
|
114
|
# Filters via django-filter to match list pattern
|
|
110
|
115
|
class MaterialFilter(filters.FilterSet):
|
|
111
|
116
|
organization = filters.ModelChoiceFilter(queryset=Organization.objects.all())
|
|
112
|
|
- category = filters.ModelChoiceFilter(queryset=MaterialCategory.objects.all())
|
|
|
117
|
+ category = filters.ChoiceFilter(choices=Material.CATEGORY_CHOICES)
|
|
113
|
118
|
name = filters.CharFilter(field_name="name", lookup_expr="icontains")
|
|
114
|
119
|
|
|
115
|
120
|
class Meta:
|
|
116
|
121
|
model = Material
|
|
117
|
122
|
fields = ["organization", "category", "name"]
|
|
118
|
123
|
|
|
119
|
|
- base_mats = Material.objects.select_related("organization", "category").order_by("organization_id", "name")
|
|
|
124
|
+ base_mats = Material.objects.select_related("organization").order_by("organization_id", "name")
|
|
120
|
125
|
mat_filter = MaterialFilter(request.GET, queryset=base_mats)
|
|
121
|
126
|
mats = mat_filter.qs
|
|
122
|
127
|
# Scope to current organization if present
|
|
|
@@ -248,7 +253,7 @@ def org_user_delete(request, pk: int):
|
|
248
|
253
|
def material_edit(request, pk: int):
|
|
249
|
254
|
item = get_object_or_404(Material, pk=pk)
|
|
250
|
255
|
if request.method == "POST":
|
|
251
|
|
- form = MaterialForm(request.POST, instance=item)
|
|
|
256
|
+ form = MaterialForm(request.POST, request.FILES, instance=item)
|
|
252
|
257
|
if form.is_valid():
|
|
253
|
258
|
form.save()
|
|
254
|
259
|
messages.success(request, "Material updated.")
|
|
|
@@ -14,4 +14,8 @@ django-browser-reload
|
|
14
|
14
|
django-allauth[socialaccount]
|
|
15
|
15
|
django-markdownfield
|
|
16
|
16
|
django-mptt
|
|
|
17
|
+django-extensions
|
|
|
18
|
+# Choose one of these for graph_models rendering
|
|
|
19
|
+# pygraphviz is preferred; alternatively install pydotplus and graphviz
|
|
|
20
|
+pygraphviz
|
|
17
|
21
|
django-npm
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+@startuml
|
|
|
2
|
+title Request Pickup (Fast Path)
|
|
|
3
|
+actor "Factory Officer" as FO
|
|
|
4
|
+participant "Public Site (FE)" as Web
|
|
|
5
|
+participant "Backend (Django)" as API
|
|
|
6
|
+actor "Staff (Web Admin)" as Staff
|
|
|
7
|
+actor Driver
|
|
|
8
|
+participant "Weigh Station" as Scale
|
|
|
9
|
+participant Billing
|
|
|
10
|
+
|
|
|
11
|
+FO -> Web: Open "Sell Scrap" (Request Pickup)
|
|
|
12
|
+Web --> FO: Show form (materials, qty, photos, address, time)
|
|
|
13
|
+FO -> Web: Submit form (+photos)
|
|
|
14
|
+Web -> API: POST /pickup-request
|
|
|
15
|
+API -> API: Create Lead (org, details)
|
|
|
16
|
+API --> Staff: Notify (inbox/email)
|
|
|
17
|
+Staff -> API: Create/attach Customer + Site
|
|
|
18
|
+Staff -> API: Create PickupOrder (status=requested)
|
|
|
19
|
+Staff -> API: Schedule + Assign Driver
|
|
|
20
|
+API --> FO: Confirmation (schedule)
|
|
|
21
|
+Driver -> FO: Arrive and collect
|
|
|
22
|
+Driver -> Scale: Weigh materials
|
|
|
23
|
+Scale -> API: Record WeighTicket + lines
|
|
|
24
|
+API -> Billing: Generate Invoice or Payout
|
|
|
25
|
+Billing --> FO: Invoice/Payout issued
|
|
|
26
|
+FO -> Billing: Pay / Receive funds
|
|
|
27
|
+API -> API: Mark Pickup completed
|
|
|
28
|
+@enduml
|
|
|
29
|
+
|
|
|
30
|
+@startuml
|
|
|
31
|
+title Get Bids (Marketplace Path)
|
|
|
32
|
+actor "Factory Officer" as FO
|
|
|
33
|
+participant "Public Site (FE)" as Web
|
|
|
34
|
+participant "Backend (Django)" as API
|
|
|
35
|
+actor "Staff (Web Admin)" as Staff
|
|
|
36
|
+actor "Recycler(s)" as Rec
|
|
|
37
|
+actor Driver
|
|
|
38
|
+participant "Weigh Station" as Scale
|
|
|
39
|
+participant Billing
|
|
|
40
|
+
|
|
|
41
|
+FO -> Web: "Sell Scrap" → Get Bids
|
|
|
42
|
+Web --> FO: Listing form (title, materials, qty, photos, reserve, ends)
|
|
|
43
|
+FO -> Web: Submit request
|
|
|
44
|
+Web -> API: POST listing-request
|
|
|
45
|
+API -> API: Create Draft ScrapListing (or Lead: listing_request)
|
|
|
46
|
+Staff -> API: Review + Publish (public or invite-only)
|
|
|
47
|
+API --> Rec: Listing visible / invites sent
|
|
|
48
|
+Rec -> API: Place Bid(s)
|
|
|
49
|
+Staff -> API: Close listing at end time
|
|
|
50
|
+Staff -> API: Award winning bid
|
|
|
51
|
+API -> API: Create PickupOrder from award
|
|
|
52
|
+Driver -> FO: Collect materials
|
|
|
53
|
+Driver -> Scale: Weigh materials
|
|
|
54
|
+Scale -> API: Record WeighTicket + lines
|
|
|
55
|
+API -> Billing: Generate Invoice or Payout
|
|
|
56
|
+Billing --> FO: Invoice/Payout issued
|
|
|
57
|
+API -> API: Mark Pickup completed
|
|
|
58
|
+@enduml
|
|
|
59
|
+
|