nordabiz/blueprints/community/classifieds/routes.py
Maciej Pienczyn cab9511498
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
fix: exempt B2B interest endpoint from CSRF validation
The /tablica/<id>/interest AJAX POST was returning 400 because
Flask-WTF CSRF validation rejected the token despite X-CSRFToken
header being present. Endpoint is protected by @login_required
and @member_required, so CSRF exemption is safe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 23:21:31 +02:00

556 lines
19 KiB
Python

"""
Classifieds Routes
==================
B2B bulletin board endpoints.
"""
from datetime import datetime, timedelta
from flask import render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from . import bp
from extensions import csrf
from database import SessionLocal, Classified, ClassifiedRead, ClassifiedInterest, ClassifiedQuestion, User
from sqlalchemy import desc
from utils.helpers import sanitize_input
from utils.decorators import member_required
@bp.route('/', endpoint='classifieds_index')
@login_required
@member_required
def index():
"""Tablica ogłoszeń B2B"""
listing_type = request.args.get('type', '')
category = request.args.get('category', '')
page = request.args.get('page', 1, type=int)
per_page = 20
db = SessionLocal()
try:
query = db.query(Classified).filter(
Classified.is_active == True
)
# Filtry
if listing_type:
query = query.filter(Classified.listing_type == listing_type)
if category:
query = query.filter(Classified.category == category)
# Sortowanie - najnowsze pierwsze
query = query.order_by(Classified.created_at.desc())
total = query.count()
classifieds = query.limit(per_page).offset((page - 1) * per_page).all()
# Kategorie do filtrów
categories = [
('uslugi', 'Usługi'),
('produkty', 'Produkty'),
('wspolpraca', 'Współpraca'),
('praca', 'Praca'),
('inne', 'Inne')
]
return render_template('classifieds/index.html',
classifieds=classifieds,
categories=categories,
listing_type=listing_type,
category_filter=category,
page=page,
total_pages=(total + per_page - 1) // per_page
)
finally:
db.close()
@bp.route('/nowe', methods=['GET', 'POST'], endpoint='classifieds_new')
@login_required
@member_required
def new():
"""Dodaj nowe ogłoszenie"""
if request.method == 'POST':
listing_type = request.form.get('listing_type', '')
category = request.form.get('category', '')
title = sanitize_input(request.form.get('title', ''), 255)
description = request.form.get('description', '').strip()
budget_info = sanitize_input(request.form.get('budget_info', ''), 255)
location_info = sanitize_input(request.form.get('location_info', ''), 255)
if not listing_type or not category or not title or not description:
flash('Wszystkie wymagane pola muszą być wypełnione.', 'error')
return render_template('classifieds/new.html')
db = SessionLocal()
try:
# Automatyczne wygaśnięcie po 30 dniach
expires = datetime.now() + timedelta(days=30)
classified = Classified(
author_id=current_user.id,
company_id=current_user.company_id,
listing_type=listing_type,
category=category,
title=title,
description=description,
budget_info=budget_info,
location_info=location_info,
expires_at=expires
)
db.add(classified)
db.commit()
flash('Ogłoszenie dodane.', 'success')
return redirect(url_for('.classifieds_index'))
finally:
db.close()
return render_template('classifieds/new.html')
@bp.route('/<int:classified_id>', endpoint='classifieds_view')
@login_required
@member_required
def view(classified_id):
"""Szczegóły ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
flash('Ogłoszenie nie istnieje.', 'error')
return redirect(url_for('.classifieds_index'))
# Zwiększ licznik wyświetleń (handle NULL)
classified.views_count = (classified.views_count or 0) + 1
# Zapisz odczyt przez zalogowanego użytkownika
existing_read = db.query(ClassifiedRead).filter(
ClassifiedRead.classified_id == classified.id,
ClassifiedRead.user_id == current_user.id
).first()
if not existing_read:
new_read = ClassifiedRead(
classified_id=classified.id,
user_id=current_user.id
)
db.add(new_read)
db.commit()
# Pobierz listę czytelników
readers = db.query(ClassifiedRead).filter(
ClassifiedRead.classified_id == classified.id
).order_by(desc(ClassifiedRead.read_at)).all()
readers_count = len(readers)
# Sprawdź czy użytkownik jest zainteresowany
user_interested = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified.id,
ClassifiedInterest.user_id == current_user.id
).first() is not None
# Liczba zainteresowanych
interests_count = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified.id
).count()
# Pobierz pytania (publiczne dla wszystkich, wszystkie dla autora)
questions_query = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified.id
)
if classified.author_id != current_user.id and not current_user.can_access_admin_panel():
questions_query = questions_query.filter(ClassifiedQuestion.is_public == True)
questions = questions_query.order_by(ClassifiedQuestion.created_at.asc()).all()
# Liczba pytań bez odpowiedzi (dla autora)
unanswered_count = 0
if classified.author_id == current_user.id:
unanswered_count = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified.id,
ClassifiedQuestion.answer == None
).count()
return render_template('classifieds/view.html',
classified=classified,
readers=readers,
readers_count=readers_count,
user_interested=user_interested,
interests_count=interests_count,
questions=questions,
unanswered_count=unanswered_count)
finally:
db.close()
@bp.route('/<int:classified_id>/zakoncz', methods=['POST'], endpoint='classifieds_close')
@login_required
@member_required
def close(classified_id):
"""Zamknij ogłoszenie"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.author_id == current_user.id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub brak uprawnień'}), 404
classified.is_active = False
db.commit()
return jsonify({'success': True, 'message': 'Ogłoszenie zamknięte'})
finally:
db.close()
@bp.route('/<int:classified_id>/delete', methods=['POST'], endpoint='classifieds_delete')
@login_required
@member_required
def delete(classified_id):
"""Usuń ogłoszenie (admin only)"""
if not current_user.can_access_admin_panel():
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
db.delete(classified)
db.commit()
return jsonify({'success': True, 'message': 'Ogłoszenie usunięte'})
finally:
db.close()
@bp.route('/<int:classified_id>/toggle-active', methods=['POST'], endpoint='classifieds_toggle_active')
@login_required
@member_required
def toggle_active(classified_id):
"""Aktywuj/dezaktywuj ogłoszenie (admin only)"""
if not current_user.can_access_admin_panel():
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
classified.is_active = not classified.is_active
db.commit()
status = 'aktywowane' if classified.is_active else 'dezaktywowane'
return jsonify({'success': True, 'message': f'Ogłoszenie {status}', 'is_active': classified.is_active})
finally:
db.close()
# ============================================================
# INTEREST (ZAINTERESOWANIA)
# ============================================================
@bp.route('/<int:classified_id>/interest', methods=['POST'], endpoint='classifieds_interest')
@csrf.exempt
@login_required
@member_required
def toggle_interest(classified_id):
"""Toggle zainteresowania ogłoszeniem"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.is_active == True
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub jest nieaktywne'}), 404
# Nie można być zainteresowanym własnym ogłoszeniem
if classified.author_id == current_user.id:
return jsonify({'success': False, 'error': 'Nie możesz być zainteresowany własnym ogłoszeniem'}), 400
# Sprawdź czy już jest zainteresowany
existing = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified_id,
ClassifiedInterest.user_id == current_user.id
).first()
if existing:
# Usuń zainteresowanie
db.delete(existing)
db.commit()
return jsonify({
'success': True,
'interested': False,
'message': 'Usunięto zainteresowanie'
})
else:
# Dodaj zainteresowanie
message = request.json.get('message', '') if request.is_json else ''
interest = ClassifiedInterest(
classified_id=classified_id,
user_id=current_user.id,
message=message[:255] if message else None
)
db.add(interest)
db.commit()
return jsonify({
'success': True,
'interested': True,
'message': 'Dodano zainteresowanie'
})
finally:
db.close()
@bp.route('/<int:classified_id>/interests', endpoint='classifieds_interests')
@login_required
@member_required
def list_interests(classified_id):
"""Lista zainteresowanych (tylko dla autora ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor może widzieć pełną listę (lub admin)
if classified.author_id != current_user.id and not current_user.can_access_admin_panel():
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
interests = db.query(ClassifiedInterest).filter(
ClassifiedInterest.classified_id == classified_id
).order_by(desc(ClassifiedInterest.created_at)).all()
return jsonify({
'success': True,
'count': len(interests),
'interests': [
{
'id': i.id,
'user_id': i.user_id,
'user_name': i.user.name or i.user.email.split('@')[0],
'user_initial': (i.user.name or i.user.email)[0].upper(),
'company_name': i.user.company.name if i.user.company else None,
'message': i.message,
'created_at': i.created_at.isoformat() if i.created_at else None
}
for i in interests
]
})
finally:
db.close()
# ============================================================
# Q&A (PYTANIA I ODPOWIEDZI)
# ============================================================
@bp.route('/<int:classified_id>/ask', methods=['POST'], endpoint='classifieds_ask')
@login_required
@member_required
def ask_question(classified_id):
"""Zadaj pytanie do ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id,
Classified.is_active == True
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje lub jest nieaktywne'}), 404
content = ''
if request.is_json:
content = request.json.get('content', '').strip()
else:
content = request.form.get('content', '').strip()
if not content:
return jsonify({'success': False, 'error': 'Treść pytania jest wymagana'}), 400
if len(content) > 2000:
return jsonify({'success': False, 'error': 'Pytanie jest zbyt długie (max 2000 znaków)'}), 400
question = ClassifiedQuestion(
classified_id=classified_id,
author_id=current_user.id,
content=content
)
db.add(question)
db.commit()
return jsonify({
'success': True,
'message': 'Pytanie dodane',
'question': {
'id': question.id,
'content': question.content,
'author_name': current_user.name or current_user.email.split('@')[0],
'created_at': question.created_at.isoformat()
}
})
finally:
db.close()
@bp.route('/<int:classified_id>/question/<int:question_id>/answer', methods=['POST'], endpoint='classifieds_answer')
@login_required
@member_required
def answer_question(classified_id, question_id):
"""Odpowiedz na pytanie (tylko autor ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor ogłoszenia może odpowiadać
if classified.author_id != current_user.id:
return jsonify({'success': False, 'error': 'Tylko autor ogłoszenia może odpowiadać na pytania'}), 403
question = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.id == question_id,
ClassifiedQuestion.classified_id == classified_id
).first()
if not question:
return jsonify({'success': False, 'error': 'Pytanie nie istnieje'}), 404
answer = ''
if request.is_json:
answer = request.json.get('answer', '').strip()
else:
answer = request.form.get('answer', '').strip()
if not answer:
return jsonify({'success': False, 'error': 'Treść odpowiedzi jest wymagana'}), 400
if len(answer) > 2000:
return jsonify({'success': False, 'error': 'Odpowiedź jest zbyt długa (max 2000 znaków)'}), 400
question.answer = answer
question.answered_by = current_user.id
question.answered_at = datetime.now()
db.commit()
return jsonify({
'success': True,
'message': 'Odpowiedź dodana',
'answer': answer,
'answered_at': question.answered_at.isoformat()
})
finally:
db.close()
@bp.route('/<int:classified_id>/question/<int:question_id>/hide', methods=['POST'], endpoint='classifieds_hide_question')
@login_required
@member_required
def hide_question(classified_id, question_id):
"""Ukryj/pokaż pytanie (tylko autor ogłoszenia)"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Tylko autor ogłoszenia lub admin może ukrywać
if classified.author_id != current_user.id and not current_user.can_access_admin_panel():
return jsonify({'success': False, 'error': 'Brak uprawnień'}), 403
question = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.id == question_id,
ClassifiedQuestion.classified_id == classified_id
).first()
if not question:
return jsonify({'success': False, 'error': 'Pytanie nie istnieje'}), 404
question.is_public = not question.is_public
db.commit()
status = 'widoczne' if question.is_public else 'ukryte'
return jsonify({
'success': True,
'message': f'Pytanie jest teraz {status}',
'is_public': question.is_public
})
finally:
db.close()
@bp.route('/<int:classified_id>/questions', endpoint='classifieds_questions')
@login_required
@member_required
def list_questions(classified_id):
"""Lista pytań i odpowiedzi do ogłoszenia"""
db = SessionLocal()
try:
classified = db.query(Classified).filter(
Classified.id == classified_id
).first()
if not classified:
return jsonify({'success': False, 'error': 'Ogłoszenie nie istnieje'}), 404
# Buduj query - autor widzi wszystkie, inni tylko publiczne
query = db.query(ClassifiedQuestion).filter(
ClassifiedQuestion.classified_id == classified_id
)
if classified.author_id != current_user.id and not current_user.can_access_admin_panel():
query = query.filter(ClassifiedQuestion.is_public == True)
questions = query.order_by(desc(ClassifiedQuestion.created_at)).all()
return jsonify({
'success': True,
'count': len(questions),
'is_owner': classified.author_id == current_user.id,
'questions': [
{
'id': q.id,
'content': q.content,
'author_id': q.author_id,
'author_name': q.author.name or q.author.email.split('@')[0],
'author_initial': (q.author.name or q.author.email)[0].upper(),
'author_company': q.author.company.name if q.author.company else None,
'answer': q.answer,
'answered_at': q.answered_at.isoformat() if q.answered_at else None,
'is_public': q.is_public,
'created_at': q.created_at.isoformat() if q.created_at else None
}
for q in questions
]
})
finally:
db.close()