rairo commited on
Commit
10fc744
·
verified ·
1 Parent(s): 37ff2cb

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +110 -166
main.py CHANGED
@@ -540,7 +540,7 @@ def user_manage_listing(listing_id):
540
  if field in data and data[field] != listing_data.get(field):
541
  update_payload[field] = data[field]
542
  requires_reapproval = True
543
-
544
  # Allow user to change status to inactive/closed without re-approval
545
  if 'status' in data and data['status'] in ['inactive', 'closed']:
546
  update_payload['status'] = data['status']
@@ -550,7 +550,7 @@ def user_manage_listing(listing_id):
550
  update_payload['status'] = 'pending_approval'
551
  else: # If no critical fields changed, allow status update if valid
552
  update_payload['status'] = data['status'] # e.g., from inactive back to active if no critical changes
553
-
554
  if requires_reapproval and update_payload.get('status') != 'pending_approval':
555
  update_payload['status'] = 'pending_approval' # Ensure pending approval if critical fields changed
556
 
@@ -559,7 +559,7 @@ def user_manage_listing(listing_id):
559
 
560
  update_payload['last_updated_at'] = datetime.now(timezone.utc).isoformat()
561
  listing_ref.update(update_payload)
562
-
563
  message = 'Listing updated successfully.'
564
  if requires_reapproval:
565
  message += ' Listing status set to pending_approval due to significant changes.'
@@ -640,13 +640,13 @@ def admin_remove_listing(listing_id):
640
 
641
  if not listing_data or not isinstance(listing_data, dict):
642
  return jsonify({'error': 'Listing not found.'}), 404
643
-
644
  lister_id = listing_data.get('lister_id')
645
  listing_type = listing_data.get('listing_type', 'item')
646
  crop_type = listing_data.get('crop_type', 'N/A')
647
 
648
  listing_ref.delete()
649
-
650
  if lister_id:
651
  _send_system_notification(lister_id, f"Your {listing_type} listing for '{crop_type}' (ID: {listing_id}) has been removed by an administrator.", "listing_removed_by_admin", f"/my-listings")
652
 
@@ -660,12 +660,14 @@ def admin_remove_listing(listing_id):
660
  @app.route('/api/deals/propose', methods=['POST'])
661
  def propose_deal():
662
  auth_header = request.headers.get('Authorization')
663
- uid = None # UID of the user making the proposal/offer
 
 
664
  try:
665
  logger.info(f"Backend /api/deals/propose received headers: {request.headers}")
666
  logger.info(f"Backend /api/deals/propose received raw data: {request.get_data(as_text=True)}")
667
 
668
- uid = verify_token(auth_header) # Can raise exceptions
669
  if not FIREBASE_INITIALIZED:
670
  return jsonify({'error': 'Server configuration error: Firebase not ready.'}), 503
671
 
@@ -674,10 +676,29 @@ def propose_deal():
674
  logger.error("Backend /api/deals/propose: No JSON data received or failed to parse.")
675
  return jsonify({'error': 'Invalid JSON data received.'}), 400
676
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
677
  listing_id = data.get('listing_id')
678
- # 'quantity' from frontend is the quantity the proposer wants to transact
679
  proposed_quantity_by_user = data.get('quantity')
680
- # 'price' from frontend is the price PER UNIT the proposer is offering/asking
681
  proposed_price_per_unit_by_user = data.get('price')
682
  notes = data.get('notes', "")
683
 
@@ -706,8 +727,8 @@ def propose_deal():
706
  original_lister_id = listing_data.get('lister_id')
707
  listing_type = listing_data.get('listing_type') # 'produce' or 'demand'
708
 
709
- if original_lister_id == uid:
710
- logger.error(f"Backend /api/deals/propose: User {uid} attempting to deal with own listing {listing_id}.")
711
  return jsonify({'error': 'Cannot propose a deal to your own listing/demand.'}), 400
712
 
713
  deal_farmer_id = None
@@ -716,9 +737,13 @@ def propose_deal():
716
  notification_recipient_id = original_lister_id # The other party
717
 
718
  if listing_type == 'produce':
719
- # Current user (proposer uid) is a BUYER, proposing to a FARMER's produce listing.
 
 
 
 
720
  deal_farmer_id = original_lister_id
721
- deal_buyer_id = uid
722
  deal_notes_prefix = "Buyer's proposal: "
723
 
724
  available_quantity = listing_data.get('quantity', 0)
@@ -727,8 +752,8 @@ def propose_deal():
727
  return jsonify({'error': f'Proposed quantity ({proposed_quantity_by_user}) exceeds available quantity ({available_quantity}) for the listing.'}), 400
728
 
729
  elif listing_type == 'demand':
730
- # Current user (proposer uid) is a FARMER, making an offer against a BUYER's demand listing.
731
- deal_farmer_id = uid
732
  deal_buyer_id = original_lister_id
733
  deal_notes_prefix = "Farmer's offer against demand: "
734
 
@@ -744,7 +769,7 @@ def propose_deal():
744
  deal_id = str(uuid.uuid4())
745
  deal_data_to_set = {
746
  'deal_id': deal_id,
747
- 'proposer_id': uid,
748
  'listing_id': listing_id,
749
  'farmer_id': deal_farmer_id,
750
  'buyer_id': deal_buyer_id,
@@ -755,32 +780,57 @@ def propose_deal():
755
  'created_at': datetime.now(timezone.utc).isoformat(),
756
  'chat_room_id': f"deal_{deal_id}"
757
  }
 
 
 
 
758
  db.reference(f'deals/{deal_id}', app=db_app).set(deal_data_to_set)
759
 
760
  _send_system_notification(
761
  notification_recipient_id,
762
- f"You have a new proposal/offer from {uid[:6]}... regarding your {listing_type} for '{listing_data.get('crop_type')}'. Qty: {proposed_quantity_by_user}, Price/Unit: {proposed_price_per_unit_by_user}",
763
  "new_deal_proposal",
764
  f"/deals/{deal_id}"
765
  )
766
- logger.info(f"Backend /api/deals/propose: Deal {deal_id} created successfully by UID {uid} for listing {listing_id}.")
767
  return jsonify({'success': True, 'message': 'Proposal/Offer submitted successfully.', 'deal': deal_data_to_set}), 201
768
 
769
  except Exception as e:
770
  # Log the original error before passing to generic handler
771
- logger.error(f"Backend /api/deals/propose: Unhandled exception for UID {uid or 'unknown'}. Error: {str(e)}\n{traceback.format_exc()}")
772
- return handle_route_errors(e, uid_context=uid or "propose_deal_unknown_user")
773
 
774
  @app.route('/api/deals/<deal_id>/respond', methods=['POST'])
775
  def respond_to_deal(deal_id):
776
  auth_header = request.headers.get('Authorization')
777
- uid = None # UID of the currently logged-in user responding
 
 
778
  try:
779
- uid = verify_token(auth_header)
780
  if not FIREBASE_INITIALIZED: return jsonify({'error': 'Server configuration error'}), 503
781
 
782
  data = request.get_json()
783
  response_action = data.get('action') # 'accept' or 'reject'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
784
 
785
  if response_action not in ['accept', 'reject']:
786
  return jsonify({'error': 'Invalid action. Must be "accept" or "reject".'}), 400
@@ -799,23 +849,23 @@ def respond_to_deal(deal_id):
799
  # Authorization Check: Who is allowed to respond?
800
  can_respond = False
801
  if current_deal_status == 'proposed':
802
- if proposer_id == buyer_id_in_deal and uid == farmer_id_in_deal: # Buyer proposed, Farmer responds
803
  can_respond = True
804
- elif proposer_id == farmer_id_in_deal and uid == buyer_id_in_deal: # Farmer proposed/countered, Buyer responds
805
  can_respond = True
806
  # Add other statuses if a different party needs to respond (e.g., after admin action)
807
 
808
  if not can_respond:
809
- logger.warning(f"UID {uid} unauthorized to respond to deal {deal_id}. Deal status: {current_deal_status}, Proposer: {proposer_id}, Farmer: {farmer_id_in_deal}, Buyer: {buyer_id_in_deal}")
810
  return jsonify({'error': 'Not authorized to respond to this deal at its current state.'}), 403
811
 
812
  update_time = datetime.now(timezone.utc).isoformat()
813
 
814
  # Determine the other party for notification
815
  other_party_id = None
816
- if uid == farmer_id_in_deal:
817
  other_party_id = buyer_id_in_deal
818
- elif uid == buyer_id_in_deal:
819
  other_party_id = farmer_id_in_deal
820
 
821
  listing_id = deal_data.get('listing_id')
@@ -824,9 +874,13 @@ def respond_to_deal(deal_id):
824
  listing_data_for_notif = db.reference(f'listings/{listing_id}', app=db_app).get() or {}
825
  crop_type_for_notif = listing_data_for_notif.get('crop_type', 'your listing/demand')
826
 
 
 
 
 
827
  if response_action == 'accept':
828
  # Quantity check (if applicable, e.g., if responding to a proposal against a produce listing)
829
- if proposer_id == buyer_id_in_deal and uid == farmer_id_in_deal: # Farmer accepting buyer's proposal
830
  if listing_id and isinstance(listing_data_for_notif, dict):
831
  available_quantity = listing_data_for_notif.get('quantity', 0)
832
  proposed_quantity = deal_data.get('proposed_quantity', 0)
@@ -840,25 +894,21 @@ def respond_to_deal(deal_id):
840
  _send_system_notification(other_party_id, f"Your deal proposal for '{crop_type_for_notif}' could not be accepted due to insufficient stock.", "deal_status_update", f"/deals/{deal_id}")
841
  return jsonify({'success': False, 'error': 'Deal could not be accepted. Listing quantity is no longer sufficient.'}), 409
842
 
843
- # If a buyer accepts a farmer's counter-offer, or farmer accepts buyer's initial proposal
844
- # The deal moves to 'accepted_by_farmer' or 'accepted_by_buyer' then to admin.
845
- # For simplicity, let's assume both lead to a state needing admin approval.
846
- # We need a clear "who accepted" field.
847
-
848
  accepted_by_field = ""
849
  new_status = ""
850
- if uid == farmer_id_in_deal:
851
  accepted_by_field = "farmer_accepted_at"
852
  new_status = "accepted_by_farmer" # Farmer accepts buyer's proposal
853
  notification_message_to_other_party = f"Your deal proposal for '{crop_type_for_notif}' has been ACCEPTED by the farmer. It is now pending admin approval."
854
- elif uid == buyer_id_in_deal:
855
  accepted_by_field = "buyer_accepted_at"
856
  new_status = "accepted_by_buyer" # Buyer accepts farmer's offer/counter
857
  notification_message_to_other_party = f"Your offer/counter-offer for '{crop_type_for_notif}' has been ACCEPTED by the buyer. It is now pending admin approval."
858
  else: # Should not happen due to auth check
859
  return jsonify({'error': 'Internal error determining accepter role.'}), 500
860
 
861
- deal_ref.update({'status': new_status, accepted_by_field: update_time, 'last_responder_id': uid})
 
862
 
863
  if other_party_id:
864
  _send_system_notification(other_party_id, notification_message_to_other_party, "deal_status_update", f"/deals/{deal_id}")
@@ -867,31 +917,32 @@ def respond_to_deal(deal_id):
867
  admins_ref = db.reference('users', app=db_app).order_by_child('is_admin').equal_to(True).get()
868
  if admins_ref:
869
  for admin_id_loop, _ in admins_ref.items():
870
- _send_system_notification(admin_id_loop, f"Deal ID {deal_id} for '{crop_type_for_notif}' has been accepted by {uid[:6]}... and needs your approval.", "admin_deal_approval_needed", f"/admin/deals/pending") # Path for admin to see pending deals
871
 
872
- return jsonify({'success': True, 'message': f'Deal accepted by {("farmer" if uid == farmer_id_in_deal else "buyer")}, pending admin approval.'}), 200
873
 
874
  elif response_action == 'reject':
875
  rejected_by_field = ""
876
  new_status = ""
877
- if uid == farmer_id_in_deal:
878
  rejected_by_field = "farmer_rejected_at" # Or just 'responded_at'
879
  new_status = "rejected_by_farmer"
880
  notification_message_to_other_party = f"Your deal proposal for '{crop_type_for_notif}' has been REJECTED by the farmer."
881
- elif uid == buyer_id_in_deal:
882
  rejected_by_field = "buyer_rejected_at"
883
  new_status = "rejected_by_buyer"
884
  notification_message_to_other_party = f"Your offer/counter-offer for '{crop_type_for_notif}' has been REJECTED by the buyer."
885
  else: # Should not happen
886
  return jsonify({'error': 'Internal error determining rejector role.'}), 500
887
 
888
- deal_ref.update({'status': new_status, rejected_by_field: update_time, 'last_responder_id': uid})
 
889
  if other_party_id:
890
  _send_system_notification(other_party_id, notification_message_to_other_party, "deal_status_update", f"/deals/{deal_id}")
891
- return jsonify({'success': True, 'message': f'Deal rejected by {("farmer" if uid == farmer_id_in_deal else "buyer")}.'}), 200
892
 
893
  except Exception as e:
894
- return handle_route_errors(e, uid_context=uid)
895
 
896
  @app.route('/api/deals/<deal_id>/complete', methods=['POST'])
897
  def complete_deal_by_admin(deal_id):
@@ -1020,12 +1071,12 @@ def admin_remove_deal(deal_id):
1020
 
1021
  if not deal_data or not isinstance(deal_data, dict):
1022
  return jsonify({'error': 'Deal not found.'}), 404
1023
-
1024
  farmer_id = deal_data.get('farmer_id')
1025
  buyer_id = deal_data.get('buyer_id')
1026
  transporter_id = deal_data.get('assigned_transporter_id')
1027
  listing_id = deal_data.get('listing_id')
1028
-
1029
  crop_type = "N/A"
1030
  if listing_id:
1031
  listing_details = db.reference(f'listings/{listing_id}', app=db_app).get()
@@ -1033,7 +1084,7 @@ def admin_remove_deal(deal_id):
1033
  crop_type = listing_details.get('crop_type', 'N/A')
1034
 
1035
  deal_ref.delete()
1036
-
1037
  message_to_parties = f"Deal ID {deal_id} for '{crop_type}' has been removed by an administrator."
1038
  if farmer_id:
1039
  _send_system_notification(farmer_id, message_to_parties, "deal_removed_by_admin", f"/my-deals")
@@ -1047,114 +1098,7 @@ def admin_remove_deal(deal_id):
1047
  except Exception as e:
1048
  return handle_route_errors(e, uid_context=admin_uid)
1049
 
1050
- # NEW: Admin Create Manual Deal (for WhatsApp farmers or any users)
1051
- @app.route('/api/admin/deals/create-manual', methods=['POST'])
1052
- def admin_create_manual_deal():
1053
- auth_header = request.headers.get('Authorization')
1054
- admin_uid = None
1055
- try:
1056
- admin_uid, _, _ = verify_admin_or_facilitator(auth_header)
1057
- if not FIREBASE_INITIALIZED: return jsonify({'error': 'Server configuration error: Firebase not ready.'}), 503
1058
-
1059
- data = request.get_json()
1060
- farmer_id = data.get('farmer_id')
1061
- buyer_id = data.get('buyer_id')
1062
- listing_id = data.get('listing_id')
1063
- quantity = data.get('quantity')
1064
- price_per_unit = data.get('price_per_unit')
1065
- notes = data.get('notes', "Admin-created deal.")
1066
-
1067
- if not all([farmer_id, buyer_id, listing_id, quantity is not None, price_per_unit is not None]):
1068
- return jsonify({'error': 'farmer_id, buyer_id, listing_id, quantity, and price_per_unit are required.'}), 400
1069
-
1070
- try:
1071
- quantity = int(quantity)
1072
- price_per_unit = float(price_per_unit)
1073
- if quantity <= 0 or price_per_unit <= 0:
1074
- raise ValueError("Quantity and price must be positive numbers.")
1075
- except ValueError as ve:
1076
- return jsonify({'error': f'Invalid quantity or price: {str(ve)}'}), 400
1077
-
1078
- # Verify farmer and buyer exist and have correct roles or are whatsapp_managed
1079
- farmer_profile = db.reference(f'users/{farmer_id}', app=db_app).get()
1080
- buyer_profile = db.reference(f'users/{buyer_id}', app=db_app).get()
1081
-
1082
- if not farmer_profile or (not farmer_profile.get('roles', {}).get('farmer') and farmer_profile.get('account_type') != 'whatsapp_managed'):
1083
- return jsonify({'error': 'Farmer ID invalid or user is not a farmer/WhatsApp farmer.'}), 400
1084
- if not buyer_profile or (not buyer_profile.get('roles', {}).get('buyer') and farmer_profile.get('account_type') != 'whatsapp_managed'):
1085
- return jsonify({'error': 'Buyer ID invalid or user is not a buyer/WhatsApp buyer.'}), 400
1086
-
1087
- # Verify listing
1088
- listing_ref = db.reference(f'listings/{listing_id}', app=db_app)
1089
- listing_data = listing_ref.get()
1090
-
1091
- if not listing_data or listing_data.get('status') != 'active':
1092
- return jsonify({'error': 'Target listing not found or not active.'}), 404
1093
-
1094
- # Ensure the listing's lister matches the farmer/buyer in the deal
1095
- if listing_data.get('listing_type') == 'produce' and listing_data.get('lister_id') != farmer_id:
1096
- return jsonify({'error': 'Produce listing lister_id does not match provided farmer_id.'}), 400
1097
- if listing_data.get('listing_type') == 'demand' and listing_data.get('lister_id') != buyer_id:
1098
- return jsonify({'error': 'Demand listing lister_id does not match provided buyer_id.'}), 400
1099
-
1100
- # Quantity check for produce listings
1101
- if listing_data.get('listing_type') == 'produce':
1102
- available_quantity = listing_data.get('quantity', 0)
1103
- if quantity > available_quantity:
1104
- return jsonify({'error': f'Proposed quantity ({quantity}) exceeds available quantity ({available_quantity}) for the listing.'}), 400
1105
-
1106
- deal_id = str(uuid.uuid4())
1107
- deal_data_to_set = {
1108
- 'deal_id': deal_id,
1109
- 'proposer_id': admin_uid, # Admin is the proposer in this context
1110
- 'listing_id': listing_id,
1111
- 'farmer_id': farmer_id,
1112
- 'buyer_id': buyer_id,
1113
- 'proposed_quantity': quantity,
1114
- 'proposed_price': price_per_unit,
1115
- 'deal_notes': notes,
1116
- 'status': 'active', # Admin-created deals are active immediately
1117
- 'created_at': datetime.now(timezone.utc).isoformat(),
1118
- 'admin_created_by': admin_uid,
1119
- 'chat_room_id': f"deal_{deal_id}"
1120
- }
1121
- db.reference(f'deals/{deal_id}', app=db_app).set(deal_data_to_set)
1122
-
1123
- # Update listing quantity if it's a produce listing
1124
- if listing_data.get('listing_type') == 'produce':
1125
- def update_listing_quantity_transaction(current_listing_data_tx):
1126
- if not current_listing_data_tx or not isinstance(current_listing_data_tx, dict):
1127
- logger.error(f"AdminCreateManualDeal: Listing {listing_id} not found or malformed during transaction for deal {deal_id}.")
1128
- return current_listing_data_tx
1129
- current_listing_quantity = current_listing_data_tx.get('quantity', 0)
1130
- new_listing_quantity = current_listing_quantity - quantity
1131
- updates = {}
1132
- if new_listing_quantity <= 0:
1133
- updates['quantity'] = 0
1134
- updates['status'] = 'closed'
1135
- else:
1136
- updates['quantity'] = new_listing_quantity
1137
- current_listing_data_tx.update(updates)
1138
- return current_listing_data_tx
1139
-
1140
- transaction_result = listing_ref.transaction(update_listing_quantity_transaction)
1141
- if transaction_result is None and listing_ref.get() is not None:
1142
- logger.warning(f"AdminCreateManualDeal: Transaction to update listing {listing_id} for deal {deal_id} was aborted or listing became null. Deal created, but listing quantity may not be updated.")
1143
- elif listing_ref.get() is None:
1144
- logger.warning(f"AdminCreateManualDeal: Listing {listing_id} not found for deal {deal_id}. Deal created, but listing quantity not updated.")
1145
- else:
1146
- logger.info(f"AdminCreateManualDeal: Listing {listing_id} quantity updated via transaction for deal {deal_id}.")
1147
-
1148
-
1149
- # Notify farmer and buyer
1150
- crop_type_for_notif = listing_data.get('crop_type', 'item')
1151
- _send_system_notification(farmer_id, f"An administrator has created a deal for you regarding '{crop_type_for_notif}' (Deal ID: {deal_id}). Quantity: {quantity}, Price/Unit: {price_per_unit}.", "admin_created_deal", f"/deals/{deal_id}")
1152
- _send_system_notification(buyer_id, f"An administrator has created a deal for you regarding '{crop_type_for_notif}' (Deal ID: {deal_id}). Quantity: {quantity}, Price/Unit: {price_per_unit}.", "admin_created_deal", f"/deals/{deal_id}")
1153
-
1154
- return jsonify({'success': True, 'message': 'Deal created manually by admin.', 'deal': deal_data_to_set}), 201
1155
-
1156
- except Exception as e:
1157
- return handle_route_errors(e, uid_context=admin_uid)
1158
 
1159
 
1160
  #--- END OF MODIFIED DEAL MANAGEMENT SECTION ---
@@ -1808,30 +1752,30 @@ def mark_notification_read(notification_id):
1808
  # Helper for AI chat to fetch platform data
1809
  def _fetch_platform_data_for_chat(query):
1810
  if not FIREBASE_INITIALIZED: return "Firebase not ready.", False
1811
-
1812
  listings_ref = db.reference('listings', app=db_app).order_by_child('status').equal_to('active')
1813
  all_active_listings = listings_ref.get() or {}
1814
-
1815
  relevant_listings = []
1816
  query_lower = query.lower()
1817
-
1818
  for lid, ldata in all_active_listings.items():
1819
  if not ldata: continue
1820
  crop_type = ldata.get('crop_type', '').lower()
1821
  location = ldata.get('location', '').lower()
1822
  listing_type = ldata.get('listing_type', '')
1823
-
1824
  # Simple keyword matching for demonstration
1825
  if any(keyword in query_lower for keyword in [crop_type, location, listing_type, 'maize', 'beans', 'harare', 'bulawayo']):
1826
  relevant_listings.append(ldata)
1827
-
1828
  if not relevant_listings:
1829
  return "No active listings found matching your query.", False
1830
-
1831
  summary = "Active Listings:\n"
1832
  for l in relevant_listings[:5]: # Limit to top 5 for brevity
1833
  summary += f"- {l.get('crop_type')} ({l.get('listing_type')}) in {l.get('location')}, Qty: {l.get('quantity')}, Price: {l.get('asking_price') or l.get('price_range')}\n"
1834
-
1835
  return summary, True
1836
 
1837
  # Helper for AI chat to fetch price trends
@@ -1855,7 +1799,7 @@ def _get_price_trend_analysis_for_chat(crop_type=None, location=None):
1855
  'date': deal.get('admin_approved_at') or deal.get('created_at'),
1856
  'crop': deal_crop_type, 'location': deal_location
1857
  })
1858
-
1859
  if not price_data_points:
1860
  return "Not enough historical data to generate price trends for the specified criteria.", False
1861
 
@@ -1885,12 +1829,12 @@ def get_price_trends():
1885
  if not gemini_client: return jsonify({'error': 'AI service not available.'}), 503
1886
  if not FIREBASE_INITIALIZED: return jsonify({'error': 'Server configuration error: Firebase not ready.'}), 503
1887
  crop_type, location = request.args.get('crop_type'), request.args.get('location')
1888
-
1889
  trend_analysis, success = _get_price_trend_analysis_for_chat(crop_type, location)
1890
-
1891
  if not success:
1892
  return jsonify({'message': trend_analysis}), 200 # trend_analysis contains the error message
1893
-
1894
  return jsonify({'crop_type': crop_type, 'location': location, 'trend_analysis': trend_analysis}), 200
1895
  except AttributeError as ae:
1896
  logger.error(f"Gemini Response Attribute Error in get_price_trends: {ae}. Response object: {response_obj}")
@@ -2025,4 +1969,4 @@ def get_ai_chat_history():
2025
 
2026
  if __name__ == '__main__':
2027
  app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
2028
-
 
540
  if field in data and data[field] != listing_data.get(field):
541
  update_payload[field] = data[field]
542
  requires_reapproval = True
543
+
544
  # Allow user to change status to inactive/closed without re-approval
545
  if 'status' in data and data['status'] in ['inactive', 'closed']:
546
  update_payload['status'] = data['status']
 
550
  update_payload['status'] = 'pending_approval'
551
  else: # If no critical fields changed, allow status update if valid
552
  update_payload['status'] = data['status'] # e.g., from inactive back to active if no critical changes
553
+
554
  if requires_reapproval and update_payload.get('status') != 'pending_approval':
555
  update_payload['status'] = 'pending_approval' # Ensure pending approval if critical fields changed
556
 
 
559
 
560
  update_payload['last_updated_at'] = datetime.now(timezone.utc).isoformat()
561
  listing_ref.update(update_payload)
562
+
563
  message = 'Listing updated successfully.'
564
  if requires_reapproval:
565
  message += ' Listing status set to pending_approval due to significant changes.'
 
640
 
641
  if not listing_data or not isinstance(listing_data, dict):
642
  return jsonify({'error': 'Listing not found.'}), 404
643
+
644
  lister_id = listing_data.get('lister_id')
645
  listing_type = listing_data.get('listing_type', 'item')
646
  crop_type = listing_data.get('crop_type', 'N/A')
647
 
648
  listing_ref.delete()
649
+
650
  if lister_id:
651
  _send_system_notification(lister_id, f"Your {listing_type} listing for '{crop_type}' (ID: {listing_id}) has been removed by an administrator.", "listing_removed_by_admin", f"/my-listings")
652
 
 
660
  @app.route('/api/deals/propose', methods=['POST'])
661
  def propose_deal():
662
  auth_header = request.headers.get('Authorization')
663
+ requester_uid = None # UID of the user making the API call (admin/user)
664
+ acting_uid = None # UID of the user on whose behalf the action is taken
665
+
666
  try:
667
  logger.info(f"Backend /api/deals/propose received headers: {request.headers}")
668
  logger.info(f"Backend /api/deals/propose received raw data: {request.get_data(as_text=True)}")
669
 
670
+ requester_uid = verify_token(auth_header) # Can raise exceptions
671
  if not FIREBASE_INITIALIZED:
672
  return jsonify({'error': 'Server configuration error: Firebase not ready.'}), 503
673
 
 
676
  logger.error("Backend /api/deals/propose: No JSON data received or failed to parse.")
677
  return jsonify({'error': 'Invalid JSON data received.'}), 400
678
 
679
+ on_behalf_of_uid = data.get('on_behalf_of_uid')
680
+
681
+ if on_behalf_of_uid:
682
+ # If acting on behalf of someone, verify the requester is an admin/facilitator
683
+ admin_or_facilitator_uid, _, _ = verify_admin_or_facilitator(auth_header) # This will raise if not admin/facilitator
684
+ acting_uid = on_behalf_of_uid
685
+
686
+ # Verify the user on whose behalf we are acting exists and is a farmer (for WhatsApp farmers)
687
+ acting_user_profile = db.reference(f'users/{acting_uid}', app=db_app).get()
688
+ if not acting_user_profile:
689
+ return jsonify({'error': f'User {acting_uid} (on_behalf_of) not found.'}), 404
690
+
691
+ # Constraint: WhatsApp farmers can only list produce, so they can only *propose* against demand listings.
692
+ # This means the acting_uid must be a farmer.
693
+ if not acting_user_profile.get('roles', {}).get('farmer') and acting_user_profile.get('account_type') != 'whatsapp_managed':
694
+ return jsonify({'error': f'User {acting_uid} is not a farmer or WhatsApp-managed farmer, cannot propose on their behalf.'}), 403
695
+
696
+ logger.info(f"Admin/Facilitator {admin_or_facilitator_uid} proposing deal on behalf of {acting_uid}.")
697
+ else:
698
+ acting_uid = requester_uid # Regular user proposing
699
+
700
  listing_id = data.get('listing_id')
 
701
  proposed_quantity_by_user = data.get('quantity')
 
702
  proposed_price_per_unit_by_user = data.get('price')
703
  notes = data.get('notes', "")
704
 
 
727
  original_lister_id = listing_data.get('lister_id')
728
  listing_type = listing_data.get('listing_type') # 'produce' or 'demand'
729
 
730
+ if original_lister_id == acting_uid:
731
+ logger.error(f"Backend /api/deals/propose: User {acting_uid} attempting to deal with own listing {listing_id}.")
732
  return jsonify({'error': 'Cannot propose a deal to your own listing/demand.'}), 400
733
 
734
  deal_farmer_id = None
 
737
  notification_recipient_id = original_lister_id # The other party
738
 
739
  if listing_type == 'produce':
740
+ # Current user (proposer acting_uid) is a BUYER, proposing to a FARMER's produce listing.
741
+ # If acting on behalf of a WhatsApp farmer, they cannot be the buyer here.
742
+ if on_behalf_of_uid and acting_user_profile.get('account_type') == 'whatsapp_managed':
743
+ return jsonify({'error': 'WhatsApp farmers can only propose deals as a farmer (against demand listings).'}), 403
744
+
745
  deal_farmer_id = original_lister_id
746
+ deal_buyer_id = acting_uid
747
  deal_notes_prefix = "Buyer's proposal: "
748
 
749
  available_quantity = listing_data.get('quantity', 0)
 
752
  return jsonify({'error': f'Proposed quantity ({proposed_quantity_by_user}) exceeds available quantity ({available_quantity}) for the listing.'}), 400
753
 
754
  elif listing_type == 'demand':
755
+ # Current user (proposer acting_uid) is a FARMER, making an offer against a BUYER's demand listing.
756
+ deal_farmer_id = acting_uid
757
  deal_buyer_id = original_lister_id
758
  deal_notes_prefix = "Farmer's offer against demand: "
759
 
 
769
  deal_id = str(uuid.uuid4())
770
  deal_data_to_set = {
771
  'deal_id': deal_id,
772
+ 'proposer_id': acting_uid, # Use acting_uid here
773
  'listing_id': listing_id,
774
  'farmer_id': deal_farmer_id,
775
  'buyer_id': deal_buyer_id,
 
780
  'created_at': datetime.now(timezone.utc).isoformat(),
781
  'chat_room_id': f"deal_{deal_id}"
782
  }
783
+ # If an admin acted on behalf, record that
784
+ if on_behalf_of_uid:
785
+ deal_data_to_set['proxied_by_admin_uid'] = requester_uid
786
+
787
  db.reference(f'deals/{deal_id}', app=db_app).set(deal_data_to_set)
788
 
789
  _send_system_notification(
790
  notification_recipient_id,
791
+ f"You have a new proposal/offer from {acting_uid[:6]}... regarding your {listing_type} for '{listing_data.get('crop_type')}'. Qty: {proposed_quantity_by_user}, Price/Unit: {proposed_price_per_unit_by_user}",
792
  "new_deal_proposal",
793
  f"/deals/{deal_id}"
794
  )
795
+ logger.info(f"Backend /api/deals/propose: Deal {deal_id} created successfully by UID {acting_uid} for listing {listing_id}.")
796
  return jsonify({'success': True, 'message': 'Proposal/Offer submitted successfully.', 'deal': deal_data_to_set}), 201
797
 
798
  except Exception as e:
799
  # Log the original error before passing to generic handler
800
+ logger.error(f"Backend /api/deals/propose: Unhandled exception for UID {acting_uid or requester_uid or 'unknown'}. Error: {str(e)}\n{traceback.format_exc()}")
801
+ return handle_route_errors(e, uid_context=acting_uid or requester_uid or "propose_deal_unknown_user")
802
 
803
  @app.route('/api/deals/<deal_id>/respond', methods=['POST'])
804
  def respond_to_deal(deal_id):
805
  auth_header = request.headers.get('Authorization')
806
+ requester_uid = None # UID of the user making the API call (admin/user)
807
+ acting_uid = None # UID of the user on whose behalf the action is taken
808
+
809
  try:
810
+ requester_uid = verify_token(auth_header)
811
  if not FIREBASE_INITIALIZED: return jsonify({'error': 'Server configuration error'}), 503
812
 
813
  data = request.get_json()
814
  response_action = data.get('action') # 'accept' or 'reject'
815
+ on_behalf_of_uid = data.get('on_behalf_of_uid')
816
+
817
+ if on_behalf_of_uid:
818
+ # If acting on behalf of someone, verify the requester is an admin/facilitator
819
+ admin_or_facilitator_uid, _, _ = verify_admin_or_facilitator(auth_header) # This will raise if not admin/facilitator
820
+ acting_uid = on_behalf_of_uid
821
+
822
+ # Verify the user on whose behalf we are acting exists and is a farmer (for WhatsApp farmers)
823
+ acting_user_profile = db.reference(f'users/{acting_uid}', app=db_app).get()
824
+ if not acting_user_profile:
825
+ return jsonify({'error': f'User {acting_uid} (on_behalf_of) not found.'}), 404
826
+
827
+ # Constraint: WhatsApp farmers can only list produce, so they would only *respond* as a farmer.
828
+ if not acting_user_profile.get('roles', {}).get('farmer') and acting_user_profile.get('account_type') != 'whatsapp_managed':
829
+ return jsonify({'error': f'User {acting_uid} is not a farmer or WhatsApp-managed farmer, cannot respond on their behalf.'}), 403
830
+
831
+ logger.info(f"Admin/Facilitator {admin_or_facilitator_uid} responding to deal {deal_id} on behalf of {acting_uid}.")
832
+ else:
833
+ acting_uid = requester_uid # Regular user responding
834
 
835
  if response_action not in ['accept', 'reject']:
836
  return jsonify({'error': 'Invalid action. Must be "accept" or "reject".'}), 400
 
849
  # Authorization Check: Who is allowed to respond?
850
  can_respond = False
851
  if current_deal_status == 'proposed':
852
+ if proposer_id == buyer_id_in_deal and acting_uid == farmer_id_in_deal: # Buyer proposed, Farmer responds
853
  can_respond = True
854
+ elif proposer_id == farmer_id_in_deal and acting_uid == buyer_id_in_deal: # Farmer proposed/countered, Buyer responds
855
  can_respond = True
856
  # Add other statuses if a different party needs to respond (e.g., after admin action)
857
 
858
  if not can_respond:
859
+ logger.warning(f"UID {acting_uid} unauthorized to respond to deal {deal_id}. Deal status: {current_deal_status}, Proposer: {proposer_id}, Farmer: {farmer_id_in_deal}, Buyer: {buyer_id_in_deal}")
860
  return jsonify({'error': 'Not authorized to respond to this deal at its current state.'}), 403
861
 
862
  update_time = datetime.now(timezone.utc).isoformat()
863
 
864
  # Determine the other party for notification
865
  other_party_id = None
866
+ if acting_uid == farmer_id_in_deal:
867
  other_party_id = buyer_id_in_deal
868
+ elif acting_uid == buyer_id_in_deal:
869
  other_party_id = farmer_id_in_deal
870
 
871
  listing_id = deal_data.get('listing_id')
 
874
  listing_data_for_notif = db.reference(f'listings/{listing_id}', app=db_app).get() or {}
875
  crop_type_for_notif = listing_data_for_notif.get('crop_type', 'your listing/demand')
876
 
877
+ update_payload = {}
878
+ if on_behalf_of_uid:
879
+ update_payload['proxied_by_admin_uid'] = requester_uid # Record who proxied the action
880
+
881
  if response_action == 'accept':
882
  # Quantity check (if applicable, e.g., if responding to a proposal against a produce listing)
883
+ if proposer_id == buyer_id_in_deal and acting_uid == farmer_id_in_deal: # Farmer accepting buyer's proposal
884
  if listing_id and isinstance(listing_data_for_notif, dict):
885
  available_quantity = listing_data_for_notif.get('quantity', 0)
886
  proposed_quantity = deal_data.get('proposed_quantity', 0)
 
894
  _send_system_notification(other_party_id, f"Your deal proposal for '{crop_type_for_notif}' could not be accepted due to insufficient stock.", "deal_status_update", f"/deals/{deal_id}")
895
  return jsonify({'success': False, 'error': 'Deal could not be accepted. Listing quantity is no longer sufficient.'}), 409
896
 
 
 
 
 
 
897
  accepted_by_field = ""
898
  new_status = ""
899
+ if acting_uid == farmer_id_in_deal:
900
  accepted_by_field = "farmer_accepted_at"
901
  new_status = "accepted_by_farmer" # Farmer accepts buyer's proposal
902
  notification_message_to_other_party = f"Your deal proposal for '{crop_type_for_notif}' has been ACCEPTED by the farmer. It is now pending admin approval."
903
+ elif acting_uid == buyer_id_in_deal:
904
  accepted_by_field = "buyer_accepted_at"
905
  new_status = "accepted_by_buyer" # Buyer accepts farmer's offer/counter
906
  notification_message_to_other_party = f"Your offer/counter-offer for '{crop_type_for_notif}' has been ACCEPTED by the buyer. It is now pending admin approval."
907
  else: # Should not happen due to auth check
908
  return jsonify({'error': 'Internal error determining accepter role.'}), 500
909
 
910
+ update_payload.update({'status': new_status, accepted_by_field: update_time, 'last_responder_id': acting_uid})
911
+ deal_ref.update(update_payload)
912
 
913
  if other_party_id:
914
  _send_system_notification(other_party_id, notification_message_to_other_party, "deal_status_update", f"/deals/{deal_id}")
 
917
  admins_ref = db.reference('users', app=db_app).order_by_child('is_admin').equal_to(True).get()
918
  if admins_ref:
919
  for admin_id_loop, _ in admins_ref.items():
920
+ _send_system_notification(admin_id_loop, f"Deal ID {deal_id} for '{crop_type_for_notif}' has been accepted by {acting_uid[:6]}... and needs your approval.", "admin_deal_approval_needed", f"/admin/deals/pending") # Path for admin to see pending deals
921
 
922
+ return jsonify({'success': True, 'message': f'Deal accepted by {("farmer" if acting_uid == farmer_id_in_deal else "buyer")}, pending admin approval.'}), 200
923
 
924
  elif response_action == 'reject':
925
  rejected_by_field = ""
926
  new_status = ""
927
+ if acting_uid == farmer_id_in_deal:
928
  rejected_by_field = "farmer_rejected_at" # Or just 'responded_at'
929
  new_status = "rejected_by_farmer"
930
  notification_message_to_other_party = f"Your deal proposal for '{crop_type_for_notif}' has been REJECTED by the farmer."
931
+ elif acting_uid == buyer_id_in_deal:
932
  rejected_by_field = "buyer_rejected_at"
933
  new_status = "rejected_by_buyer"
934
  notification_message_to_other_party = f"Your offer/counter-offer for '{crop_type_for_notif}' has been REJECTED by the buyer."
935
  else: # Should not happen
936
  return jsonify({'error': 'Internal error determining rejector role.'}), 500
937
 
938
+ update_payload.update({'status': new_status, rejected_by_field: update_time, 'last_responder_id': acting_uid})
939
+ deal_ref.update(update_payload)
940
  if other_party_id:
941
  _send_system_notification(other_party_id, notification_message_to_other_party, "deal_status_update", f"/deals/{deal_id}")
942
+ return jsonify({'success': True, 'message': f'Deal rejected by {("farmer" if acting_uid == farmer_id_in_deal else "buyer")}.'}), 200
943
 
944
  except Exception as e:
945
+ return handle_route_errors(e, uid_context=acting_uid or requester_uid)
946
 
947
  @app.route('/api/deals/<deal_id>/complete', methods=['POST'])
948
  def complete_deal_by_admin(deal_id):
 
1071
 
1072
  if not deal_data or not isinstance(deal_data, dict):
1073
  return jsonify({'error': 'Deal not found.'}), 404
1074
+
1075
  farmer_id = deal_data.get('farmer_id')
1076
  buyer_id = deal_data.get('buyer_id')
1077
  transporter_id = deal_data.get('assigned_transporter_id')
1078
  listing_id = deal_data.get('listing_id')
1079
+
1080
  crop_type = "N/A"
1081
  if listing_id:
1082
  listing_details = db.reference(f'listings/{listing_id}', app=db_app).get()
 
1084
  crop_type = listing_details.get('crop_type', 'N/A')
1085
 
1086
  deal_ref.delete()
1087
+
1088
  message_to_parties = f"Deal ID {deal_id} for '{crop_type}' has been removed by an administrator."
1089
  if farmer_id:
1090
  _send_system_notification(farmer_id, message_to_parties, "deal_removed_by_admin", f"/my-deals")
 
1098
  except Exception as e:
1099
  return handle_route_errors(e, uid_context=admin_uid)
1100
 
1101
+ # Removed the admin_create_manual_deal endpoint as its functionality is now integrated into propose_deal and respond_to_deal.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
 
1103
 
1104
  #--- END OF MODIFIED DEAL MANAGEMENT SECTION ---
 
1752
  # Helper for AI chat to fetch platform data
1753
  def _fetch_platform_data_for_chat(query):
1754
  if not FIREBASE_INITIALIZED: return "Firebase not ready.", False
1755
+
1756
  listings_ref = db.reference('listings', app=db_app).order_by_child('status').equal_to('active')
1757
  all_active_listings = listings_ref.get() or {}
1758
+
1759
  relevant_listings = []
1760
  query_lower = query.lower()
1761
+
1762
  for lid, ldata in all_active_listings.items():
1763
  if not ldata: continue
1764
  crop_type = ldata.get('crop_type', '').lower()
1765
  location = ldata.get('location', '').lower()
1766
  listing_type = ldata.get('listing_type', '')
1767
+
1768
  # Simple keyword matching for demonstration
1769
  if any(keyword in query_lower for keyword in [crop_type, location, listing_type, 'maize', 'beans', 'harare', 'bulawayo']):
1770
  relevant_listings.append(ldata)
1771
+
1772
  if not relevant_listings:
1773
  return "No active listings found matching your query.", False
1774
+
1775
  summary = "Active Listings:\n"
1776
  for l in relevant_listings[:5]: # Limit to top 5 for brevity
1777
  summary += f"- {l.get('crop_type')} ({l.get('listing_type')}) in {l.get('location')}, Qty: {l.get('quantity')}, Price: {l.get('asking_price') or l.get('price_range')}\n"
1778
+
1779
  return summary, True
1780
 
1781
  # Helper for AI chat to fetch price trends
 
1799
  'date': deal.get('admin_approved_at') or deal.get('created_at'),
1800
  'crop': deal_crop_type, 'location': deal_location
1801
  })
1802
+
1803
  if not price_data_points:
1804
  return "Not enough historical data to generate price trends for the specified criteria.", False
1805
 
 
1829
  if not gemini_client: return jsonify({'error': 'AI service not available.'}), 503
1830
  if not FIREBASE_INITIALIZED: return jsonify({'error': 'Server configuration error: Firebase not ready.'}), 503
1831
  crop_type, location = request.args.get('crop_type'), request.args.get('location')
1832
+
1833
  trend_analysis, success = _get_price_trend_analysis_for_chat(crop_type, location)
1834
+
1835
  if not success:
1836
  return jsonify({'message': trend_analysis}), 200 # trend_analysis contains the error message
1837
+
1838
  return jsonify({'crop_type': crop_type, 'location': location, 'trend_analysis': trend_analysis}), 200
1839
  except AttributeError as ae:
1840
  logger.error(f"Gemini Response Attribute Error in get_price_trends: {ae}. Response object: {response_obj}")
 
1969
 
1970
  if __name__ == '__main__':
1971
  app.run(debug=True, host="0.0.0.0", port=int(os.getenv("PORT", 7860)))
1972
+ `