="lines-code">
+      >]
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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/>&lt;<FONT FACE="Roboto"><I>TimestampedModel</I></FONT>&gt;
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
+}

BIN
erd.pdf


BIN
erd.png


+ 4 - 1
public_frontend/forms.py

@@ -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
-

+ 5 - 1
public_frontend/templates/public_frontend/home.html

@@ -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>

+ 1 - 2
public_frontend/templates/public_frontend/materials_list.html

@@ -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
-

+ 5 - 2
public_frontend/templates/public_frontend/pickup_request.html

@@ -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
-

+ 20 - 17
public_frontend/views.py

@@ -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
 

+ 5 - 1
recycle_core/admin.py

@@ -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)

+ 2 - 0
recycle_core/controllers/__init__.py

@@ -0,0 +1,2 @@
1
+from __future__ import annotations
2
+

+ 80 - 0
recycle_core/controllers/pickup_request.py

@@ -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
+

+ 51 - 1
recycle_core/forms.py

@@ -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):

+ 69 - 4
recycle_core/management/commands/seed_ecoloop.py

@@ -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(

+ 18 - 0
recycle_core/migrations/0006_alter_materialcategory_name.py

@@ -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
+    ]

+ 18 - 0
recycle_core/migrations/0007_alter_material_category.py

@@ -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
+    ]

+ 29 - 0
recycle_core/migrations/0008_materialimage.py

@@ -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
+    ]

+ 30 - 2
recycle_core/models.py

@@ -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)

+ 22 - 1
recycle_core/templates/recycle_core/material_form.html

@@ -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">

+ 1 - 1
recycle_core/templates/recycle_core/materials_list.html

@@ -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>

+ 64 - 1
recycle_core/tests.py

@@ -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)

+ 9 - 4
recycle_core/views.py

@@ -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.")

+ 4 - 0
requirements.txt

@@ -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

BIN
seq.png


+ 59 - 0
seq.txt

@@ -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
+

BIN
seq_001.png


golf/tge - Gogs: Simplico Git Service

暫無描述

golf d8e79ace03 index commit 2 年之前
..
.github d8e79ace03 index commit 2 年之前
bin d8e79ace03 index commit 2 年之前
example d8e79ace03 index commit 2 年之前
lib d8e79ace03 index commit 2 年之前
test d8e79ace03 index commit 2 年之前
.editorconfig d8e79ace03 index commit 2 年之前
.eslintrc d8e79ace03 index commit 2 年之前
LICENSE d8e79ace03 index commit 2 年之前
SECURITY.md d8e79ace03 index commit 2 年之前
async.js d8e79ace03 index commit 2 年之前
index.js d8e79ace03 index commit 2 年之前
package.json d8e79ace03 index commit 2 年之前
readme.markdown d8e79ace03 index commit 2 年之前
sync.js d8e79ace03 index commit 2 年之前

readme.markdown

resolve Version Badge

implements the node require.resolve() algorithm such that you can require.resolve() on behalf of a file asynchronously and synchronously

github actions coverage dependency status dev dependency status License Downloads

npm badge

example

asynchronously resolve:

var resolve = require('resolve/async'); // or, require('resolve')
resolve('tap', { basedir: __dirname }, function (err, res) {
    if (err) console.error(err);
    else console.log(res);
});
$ node example/async.js
/home/substack/projects/node-resolve/node_modules/tap/lib/main.js

synchronously resolve:

var resolve = require('resolve/sync'); // or, `require('resolve').sync
var res = resolve('tap', { basedir: __dirname });
console.log(res);
$ node example/sync.js
/home/substack/projects/node-resolve/node_modules/tap/lib/main.js

methods

var resolve = require('resolve');
var async = require('resolve/async');
var sync = require('resolve/sync');

For both the synchronous and asynchronous methods, errors may have any of the following err.code values:

  • MODULE_NOT_FOUND: the given path string (id) could not be resolved to a module
  • INVALID_BASEDIR: the specified opts.basedir doesn't exist, or is not a directory
  • INVALID_PACKAGE_MAIN: a package.json was encountered with an invalid main property (eg. not a string)

resolve(id, opts={}, cb)

Asynchronously resolve the module path string id into cb(err, res [, pkg]), where pkg (if defined) is the data from package.json.

options are:

  • opts.basedir - directory to begin resolving from

  • opts.package - package.json data applicable to the module being loaded

  • opts.extensions - array of file extensions to search in order

  • opts.includeCoreModules - set to false to exclude node core modules (e.g. fs) from the search

  • opts.readFile - how to read files asynchronously

  • opts.isFile - function to asynchronously test whether a file exists

  • opts.isDirectory - function to asynchronously test whether a file exists and is a directory

  • opts.realpath - function to asynchronously resolve a potential symlink to its real path

  • opts.readPackage(readFile, pkgfile, cb) - function to asynchronously read and parse a package.json file

    • readFile - the passed opts.readFile or fs.readFile if not specified
    • pkgfile - path to package.json
    • cb - callback
  • opts.packageFilter(pkg, pkgfile, dir) - transform the parsed package.json contents before looking at the "main" field

    • pkg - package data
    • pkgfile - path to package.json
    • dir - directory that contains package.json
  • opts.pathFilter(pkg, path, relativePath) - transform a path within a package

    • pkg - package data
    • path - the path being resolved
    • relativePath - the path relative from the package.json location
    • returns - a relative path that will be joined from the package.json location
  • opts.paths - require.paths array to use if nothing is found on the normal node_modules recursive walk (probably don't use this)

For advanced users, paths can also be a opts.paths(request, start, opts) function

* request - the import specifier being resolved
* start - lookup path
* getNodeModulesDirs - a thunk (no-argument function) that returns the paths using standard `node_modules` resolution
* opts - the resolution options
  • opts.packageIterator(request, start, opts) - return the list of candidate paths where the packages sources may be found (probably don't use this)

    • request - the import specifier being resolved
    • start - lookup path
    • getPackageCandidates - a thunk (no-argument function) that returns the paths using standard node_modules resolution
    • opts - the resolution options
  • opts.moduleDirectory - directory (or directories) in which to recursively look for modules. default: "node_modules"

  • opts.preserveSymlinks - if true, doesn't resolve basedir to real path before resolving. This is the way Node resolves dependencies when executed with the --preserve-symlinks flag. Note: this property is currently true by default but it will be changed to false in the next major version because Node's resolution algorithm does not preserve symlinks by default.

default opts values:

{
    paths: [],
    basedir: __dirname,
    extensions: ['.js'],
    includeCoreModules: true,
    readFile: fs.readFile,
    isFile: function isFile(file, cb) {
        fs.stat(file, function (err, stat) {
            if (!err) {
                return cb(null, stat.isFile() || stat.isFIFO());
            }
            if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return cb(null, false);
            return cb(err);
        });
    },
    isDirectory: function isDirectory(dir, cb) {
        fs.stat(dir, function (err, stat) {
            if (!err) {
                return cb(null, stat.isDirectory());
            }
            if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return cb(null, false);
            return cb(err);
        });
    },
    realpath: function realpath(file, cb) {
        var realpath = typeof fs.realpath.native === 'function' ? fs.realpath.native : fs.realpath;
        realpath(file, function (realPathErr, realPath) {
            if (realPathErr && realPathErr.code !== 'ENOENT') cb(realPathErr);
            else cb(null, realPathErr ? file : realPath);
        });
    },
    readPackage: function defaultReadPackage(readFile, pkgfile, cb) {
        readFile(pkgfile, function (readFileErr, body) {
            if (readFileErr) cb(readFileErr);
            else {
                try {
                    var pkg = JSON.parse(body);
                    cb(null, pkg);
                } catch (jsonErr) {
                    cb(null);
                }
            }
        });
    },
    moduleDirectory: 'node_modules',
    preserveSymlinks: true
}

resolve.sync(id, opts)

Synchronously resolve the module path string id, returning the result and throwing an error when id can't be resolved.

options are:

  • opts.basedir - directory to begin resolving from

  • opts.extensions - array of file extensions to search in order

  • opts.includeCoreModules - set to false to exclude node core modules (e.g. fs) from the search

  • opts.readFileSync - how to read files synchronously

  • opts.isFile - function to synchronously test whether a file exists

  • opts.isDirectory - function to synchronously test whether a file exists and is a directory

  • opts.realpathSync - function to synchronously resolve a potential symlink to its real path

  • opts.readPackageSync(readFileSync, pkgfile) - function to synchronously read and parse a package.json file

    • readFileSync - the passed opts.readFileSync or fs.readFileSync if not specified
    • pkgfile - path to package.json
  • opts.packageFilter(pkg, dir) - transform the parsed package.json contents before looking at the "main" field

    • pkg - package data
    • dir - directory that contains package.json (Note: the second argument will change to "pkgfile" in v2)
  • opts.pathFilter(pkg, path, relativePath) - transform a path within a package

    • pkg - package data
    • path - the path being resolved
    • relativePath - the path relative from the package.json location
    • returns - a relative path that will be joined from the package.json location
  • opts.paths - require.paths array to use if nothing is found on the normal node_modules recursive walk (probably don't use this)

For advanced users, paths can also be a opts.paths(request, start, opts) function

* request - the import specifier being resolved
* start - lookup path
* getNodeModulesDirs - a thunk (no-argument function) that returns the paths using standard `node_modules` resolution
* opts - the resolution options
  • opts.packageIterator(request, start, opts) - return the list of candidate paths where the packages sources may be found (probably don't use this)

    • request - the import specifier being resolved
    • start - lookup path
    • getPackageCandidates - a thunk (no-argument function) that returns the paths using standard node_modules resolution
    • opts - the resolution options
  • opts.moduleDirectory - directory (or directories) in which to recursively look for modules. default: "node_modules"

  • opts.preserveSymlinks - if true, doesn't resolve basedir to real path before resolving. This is the way Node resolves dependencies when executed with the --preserve-symlinks flag. Note: this property is currently true by default but it will be changed to false in the next major version because Node's resolution algorithm does not preserve symlinks by default.

default opts values:

{
    paths: [],
    basedir: __dirname,
    extensions: ['.js'],
    includeCoreModules: true,
    readFileSync: fs.readFileSync,
    isFile: function isFile(file) {
        try {
            var stat = fs.statSync(file);
        } catch (e) {
            if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false;
            throw e;
        }
        return stat.isFile() || stat.isFIFO();
    },
    isDirectory: function isDirectory(dir) {
        try {
            var stat = fs.statSync(dir);
        } catch (e) {
            if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return false;
            throw e;
        }
        return stat.isDirectory();
    },
    realpathSync: function realpathSync(file) {
        try {
            var realpath = typeof fs.realpathSync.native === 'function' ? fs.realpathSync.native : fs.realpathSync;
            return realpath(file);
        } catch (realPathErr) {
            if (realPathErr.code !== 'ENOENT') {
                throw realPathErr;
            }
        }
        return file;
    },
    readPackageSync: function defaultReadPackageSync(readFileSync, pkgfile) {
        var body = readFileSync(pkgfile);
        try {
            var pkg = JSON.parse(body);
            return pkg;
        } catch (jsonErr) {}
    },
    moduleDirectory: 'node_modules',
    preserveSymlinks: true
}

install

With npm do:

npm install resolve

license

MIT