auto-claude: subtask-7-3 - Handle edge cases for IT audit

Edge cases handled:
1. Partial submission:
   - Added is_partial flag to save response
   - Dynamic success message based on completeness score
   - Completeness threshold messages (< 30%, 30-70%, > 70%)

2. Company without audit:
   - Fixed template to show "Brak audytu" for companies without audit
   - Added "Utwórz audyt" button (+ icon) for companies without audit
   - Fixed data structure mismatch between route and template

3. Multiple audit history:
   - Added get_company_audit_history() convenience function
   - Added has_company_audit() helper function
   - Added /api/it-audit/history/<company_id> API endpoint
   - Returns history_count in save response

Other fixes:
- Fixed stats variable naming in admin_it_audit route
- Fixed collaboration_matches data structure for template
- Fixed url_for to use slug instead of company_id
- Fixed match_type filter (shared_licensing not shared_m365_licensing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Maciej Pienczyn 2026-01-09 09:30:50 +01:00
parent fa45b4b793
commit b405fdd810
3 changed files with 254 additions and 81 deletions

202
app.py
View File

@ -5293,47 +5293,77 @@ def admin_it_audit():
).scalar()
collab_stats[flag] = count
# Get collaboration matches
matches = db.query(
ITCollaborationMatch,
Company.name.label('company_a_name'),
Company.slug.label('company_a_slug')
).join(
Company, ITCollaborationMatch.company_a_id == Company.id
).order_by(
# Get collaboration matches with both companies' info
matches = db.query(ITCollaborationMatch).order_by(
ITCollaborationMatch.match_score.desc()
).all()
# Organize matches by type
matches_by_type = {}
for match_row in matches:
match = match_row[0]
match_type = match.match_type
if match_type not in matches_by_type:
matches_by_type[match_type] = []
# Build flat list of collaboration matches with all necessary attributes
class CollabMatchRow:
"""Helper class for template attribute access"""
def __init__(self, **kwargs):
for key, value in kwargs.items():
setattr(self, key, value)
# Get company B info
collaboration_matches = []
for match in matches:
# Get company A and B info
company_a = db.query(Company).filter(Company.id == match.company_a_id).first()
company_b = db.query(Company).filter(Company.id == match.company_b_id).first()
matches_by_type[match_type].append({
'id': match.id,
'company_a': {'name': match_row.company_a_name, 'slug': match_row.company_a_slug},
'company_b': {'name': company_b.name if company_b else 'Nieznana', 'slug': company_b.slug if company_b else ''},
'match_reason': match.match_reason,
'match_score': match.match_score,
'status': match.status
})
collaboration_matches.append(CollabMatchRow(
id=match.id,
match_type=match.match_type,
company_a_id=match.company_a_id,
company_a_name=company_a.name if company_a else 'Nieznana',
company_a_slug=company_a.slug if company_a else '',
company_b_id=match.company_b_id,
company_b_name=company_b.name if company_b else 'Nieznana',
company_b_slug=company_b.slug if company_b else '',
match_reason=match.match_reason,
match_score=match.match_score,
status=match.status,
created_at=match.created_at
))
stats = {
# Main stats
'total_audits': len(audited_companies),
'not_audited_count': len(not_audited),
'avg_overall': avg_overall,
'avg_security': avg_security,
'avg_collaboration': avg_collaboration,
'total_companies': len(companies),
'companies_without_audit': len(not_audited),
# Score averages
'avg_overall_score': avg_overall,
'avg_security_score': avg_security,
'avg_collaboration_score': avg_collaboration,
# Maturity distribution (flattened for template)
'maturity_basic': maturity_counts['basic'],
'maturity_developing': maturity_counts['developing'],
'maturity_established': maturity_counts['established'],
'maturity_advanced': maturity_counts['advanced'],
# Technology adoption stats (matching template naming with has_* prefix)
'has_azure_ad': tech_stats['azure_ad'],
'has_m365': tech_stats['m365'],
'has_proxmox_pbs': tech_stats['proxmox_pbs'],
'has_zabbix': tech_stats['zabbix'],
'has_edr': tech_stats['edr'],
'has_dr_plan': tech_stats['dr_plan'],
# Collaboration flags
'open_to_shared_licensing': collab_stats.get('open_to_shared_licensing', 0),
'open_to_backup_replication': collab_stats.get('open_to_backup_replication', 0),
'open_to_teams_federation': collab_stats.get('open_to_teams_federation', 0),
'open_to_shared_monitoring': collab_stats.get('open_to_shared_monitoring', 0),
'open_to_collective_purchasing': collab_stats.get('open_to_collective_purchasing', 0),
'open_to_knowledge_sharing': collab_stats.get('open_to_knowledge_sharing', 0),
# Legacy nested structures (for any templates that still use them)
'maturity_counts': maturity_counts,
'tech_stats': tech_stats,
'collab_stats': collab_stats,
'total_matches': len(matches)
'total_matches': len(collaboration_matches)
}
# Convert companies list to objects with attribute access for template
@ -5347,7 +5377,7 @@ def admin_it_audit():
return render_template('admin/it_audit_dashboard.html',
companies=companies_objects,
stats=stats,
matches_by_type=matches_by_type,
collaboration_matches=collaboration_matches,
now=datetime.now()
)
@ -5529,16 +5559,36 @@ def it_audit_save():
service = ITAuditService(db)
audit = service.save_audit(company_id, audit_data)
# Check if this is a partial submission (completeness < 100)
is_partial = audit.completeness_score < 100 if audit.completeness_score else True
# Count previous audits for this company (to indicate if history exists)
audit_history_count = db.query(ITAudit).filter(
ITAudit.company_id == company_id
).count()
logger.info(
f"IT audit saved by {current_user.email} for company {company.name}: "
f"overall={audit.overall_score}, security={audit.security_score}, "
f"collaboration={audit.collaboration_score}, completeness={audit.completeness_score}"
f"{' (partial)' if is_partial else ''}"
)
# Return success response
# Build appropriate success message
if is_partial:
if audit.completeness_score < 30:
message = f'Audyt IT został zapisany. Formularz wypełniony w {audit.completeness_score}%. Uzupełnij więcej sekcji, aby uzyskać pełniejszy obraz infrastruktury IT.'
elif audit.completeness_score < 70:
message = f'Audyt IT został zapisany. Wypełniono {audit.completeness_score}% formularza. Rozważ uzupełnienie pozostałych sekcji.'
else:
message = f'Audyt IT został zapisany. Formularz prawie kompletny ({audit.completeness_score}%).'
else:
message = 'Audyt IT został zapisany pomyślnie. Formularz jest kompletny.'
# Return success response with detailed information
return jsonify({
'success': True,
'message': 'Audyt IT został zapisany pomyślnie.',
'message': message,
'company_id': company.id,
'company_name': company.name,
'company_slug': company.slug,
@ -5550,7 +5600,9 @@ def it_audit_save():
'collaboration_score': audit.collaboration_score,
'completeness_score': audit.completeness_score,
'maturity_level': audit.maturity_level,
'is_partial': is_partial,
},
'history_count': audit_history_count, # Number of audits for this company (including current)
'redirect_url': url_for('company_detail', slug=company.slug)
}), 200
@ -5759,6 +5811,92 @@ def api_it_audit_matches(company_id):
db.close()
@app.route('/api/it-audit/history/<int:company_id>')
@login_required
def api_it_audit_history(company_id):
"""
API: Get IT audit history for a company.
Returns a list of all IT audits for a company, ordered by date descending.
The first item in the list is always the latest (current) audit.
Access:
- Admin: Can view history for any company
- User: Can only view history for their own company
Args:
company_id: Company ID to get audit history for
Query params:
limit: Maximum number of audits to return (default: 10)
Returns:
JSON with list of audits including:
- audit_id, audit_date, overall_score, scores, maturity_level
- is_current flag (True for the most recent audit)
"""
from it_audit_service import get_company_audit_history
# Access control: users can only view their own company's history
if not current_user.is_admin and current_user.company_id != company_id:
return jsonify({
'success': False,
'error': 'Brak uprawnień do przeglądania historii audytów tej firmy.'
}), 403
# Parse limit from query params
limit = request.args.get('limit', 10, type=int)
limit = min(max(limit, 1), 50) # Clamp to 1-50
db = SessionLocal()
try:
# Verify company exists
company = db.query(Company).filter_by(id=company_id).first()
if not company:
return jsonify({
'success': False,
'error': 'Firma nie znaleziona'
}), 404
# Get audit history
audits = get_company_audit_history(db, company_id, limit)
# Format response
history = []
for idx, audit in enumerate(audits):
history.append({
'id': audit.id,
'audit_date': audit.audit_date.isoformat() if audit.audit_date else None,
'audit_source': audit.audit_source,
'overall_score': audit.overall_score,
'security_score': audit.security_score,
'collaboration_score': audit.collaboration_score,
'completeness_score': audit.completeness_score,
'maturity_level': audit.maturity_level,
'is_current': idx == 0, # First item is most recent
'is_partial': (audit.completeness_score or 0) < 100,
})
return jsonify({
'success': True,
'company_id': company_id,
'company_name': company.name,
'company_slug': company.slug,
'total_audits': len(history),
'history': history
}), 200
except Exception as e:
logger.error(f"Error fetching IT audit history for company {company_id}: {e}")
return jsonify({
'success': False,
'error': f'Błąd podczas pobierania historii audytów: {str(e)}'
}), 500
finally:
db.close()
# ============================================================
# ERROR HANDLERS
# ============================================================

View File

@ -912,6 +912,36 @@ def calculate_scores(audit_data: dict) -> ITAuditResult:
return service.calculate_scores(audit_data)
def get_company_audit_history(db: Session, company_id: int, limit: int = 10) -> List[ITAudit]:
"""
Get audit history for a company.
Args:
db: Database session
company_id: Company ID
limit: Maximum number of audits to return
Returns:
List of ITAudit records ordered by date descending
"""
service = ITAuditService(db)
return service.get_audit_history(company_id, limit)
def has_company_audit(db: Session, company_id: int) -> bool:
"""
Check if a company has any IT audit.
Args:
db: Database session
company_id: Company ID
Returns:
True if company has at least one audit, False otherwise
"""
return db.query(ITAudit).filter(ITAudit.company_id == company_id).first() is not None
# === Main for Testing ===
if __name__ == '__main__':

View File

@ -985,7 +985,7 @@
{% if collaboration_matches %}
<div class="collab-matrix-grid">
<!-- Shared M365 Licensing -->
{% set m365_matches = collaboration_matches|selectattr('match_type', 'equalto', 'shared_m365_licensing')|list %}
{% set m365_matches = collaboration_matches|selectattr('match_type', 'equalto', 'shared_licensing')|list %}
{% if m365_matches %}
<div class="match-type-card">
<h3>
@ -1003,9 +1003,9 @@
{% for match in m365_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1033,9 +1033,9 @@
{% for match in backup_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1063,9 +1063,9 @@
{% for match in teams_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1093,9 +1093,9 @@
{% for match in monitoring_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1123,9 +1123,9 @@
{% for match in purchasing_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1153,9 +1153,9 @@
{% for match in knowledge_matches %}
<div class="match-pair">
<div class="match-pair-companies">
<a href="{{ url_for('company_detail', company_id=match.company_a_id) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_a_slug) }}" title="{{ match.company_a_name }}">{{ match.company_a_name }}</a>
<span class="match-pair-separator"></span>
<a href="{{ url_for('company_detail', company_id=match.company_b_id) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
<a href="{{ url_for('company_detail', slug=match.company_b_slug) }}" title="{{ match.company_b_name }}">{{ match.company_b_name }}</a>
</div>
<span class="match-status-badge {{ match.status }}">{{ match.status }}</span>
</div>
@ -1255,76 +1255,77 @@
</thead>
<tbody id="auditTableBody">
{% for company in companies %}
{% set audit = company.it_audit %}
{# company object has flat audit data (overall_score, security_score, etc.) directly on it #}
{% set has_audit = company.overall_score is not none %}
{% set maturity_level = 'none' %}
{% if audit %}
{% if audit.overall_score < 40 %}
{% if has_audit %}
{% if company.overall_score < 40 %}
{% set maturity_level = 'basic' %}
{% elif audit.overall_score < 60 %}
{% elif company.overall_score < 60 %}
{% set maturity_level = 'developing' %}
{% elif audit.overall_score < 80 %}
{% elif company.overall_score < 80 %}
{% set maturity_level = 'established' %}
{% else %}
{% set maturity_level = 'advanced' %}
{% endif %}
{% endif %}
<tr data-name="{{ company.name|lower }}"
data-overall="{{ audit.overall_score if audit else -1 }}"
data-security="{{ audit.security_score if audit else -1 }}"
data-collaboration="{{ audit.collaboration_score if audit else -1 }}"
data-overall="{{ company.overall_score if has_audit else -1 }}"
data-security="{{ company.security_score if has_audit else -1 }}"
data-collaboration="{{ company.collaboration_score if has_audit else -1 }}"
data-maturity="{{ maturity_level }}">
<td class="company-name-cell">
<a href="{{ url_for('company_detail', company_id=company.id) }}">{{ company.name }}</a>
<a href="{{ url_for('company_detail', slug=company.slug) }}">{{ company.name }}</a>
</td>
<td class="score-cell">
{% if audit %}
<span class="score-badge overall-score {{ 'score-good' if audit.overall_score >= 80 else ('score-medium' if audit.overall_score >= 40 else 'score-poor') }}">
{{ audit.overall_score }}
{% if has_audit %}
<span class="score-badge overall-score {{ 'score-good' if company.overall_score >= 80 else ('score-medium' if company.overall_score >= 40 else 'score-poor') }}">
{{ company.overall_score }}
</span>
{% else %}
<span class="score-badge score-na">Brak audytu</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if has_audit %}
<span class="score-badge {{ 'score-good' if company.security_score >= 80 else ('score-medium' if company.security_score >= 40 else 'score-poor') }}">
{{ company.security_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if audit %}
<span class="score-badge {{ 'score-good' if audit.security_score >= 80 else ('score-medium' if audit.security_score >= 40 else 'score-poor') }}">
{{ audit.security_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td class="score-cell hide-mobile">
{% if audit %}
<span class="score-badge {{ 'score-good' if audit.collaboration_score >= 80 else ('score-medium' if audit.collaboration_score >= 40 else 'score-poor') }}">
{{ audit.collaboration_score }}
{% if has_audit %}
<span class="score-badge {{ 'score-good' if company.collaboration_score >= 80 else ('score-medium' if company.collaboration_score >= 40 else 'score-poor') }}">
{{ company.collaboration_score }}
</span>
{% else %}
<span class="score-badge score-na">-</span>
{% endif %}
</td>
<td>
{% if audit %}
{% if has_audit %}
<span class="maturity-badge {{ maturity_level }}">
{% if maturity_level == 'basic' %}Podstawowy
{% elif maturity_level == 'developing' %}Rozwijajacy
{% elif maturity_level == 'developing' %}Rozwijający
{% elif maturity_level == 'established' %}Ugruntowany
{% elif maturity_level == 'advanced' %}Zaawansowany
{% endif %}
</span>
{% else %}
<span class="maturity-badge" style="background: var(--border); color: var(--text-secondary);">Brak</span>
<span class="maturity-badge" style="background: var(--border); color: var(--text-secondary);">Brak audytu</span>
{% endif %}
</td>
<td class="hide-mobile">
{% if audit %}
{% if has_audit %}
<div class="tech-icons">
<span class="tech-icon {{ 'azure' if audit.has_azure_ad else 'inactive' }}" title="Azure AD / Entra ID">Az</span>
<span class="tech-icon {{ 'm365' if audit.has_m365 else 'inactive' }}" title="Microsoft 365">M3</span>
<span class="tech-icon {{ 'pbs' if audit.has_proxmox_pbs else 'inactive' }}" title="Proxmox Backup Server">PB</span>
<span class="tech-icon {{ 'zabbix' if audit.has_zabbix else 'inactive' }}" title="Zabbix Monitoring">Zb</span>
<span class="tech-icon {{ 'edr' if audit.has_edr else 'inactive' }}" title="EDR / XDR">ED</span>
<span class="tech-icon {{ 'dr' if audit.has_dr_plan else 'inactive' }}" title="Plan DR">DR</span>
<span class="tech-icon {{ 'azure' if company.has_azure_ad else 'inactive' }}" title="Azure AD / Entra ID">Az</span>
<span class="tech-icon {{ 'm365' if company.has_m365 else 'inactive' }}" title="Microsoft 365">M3</span>
<span class="tech-icon {{ 'pbs' if company.has_proxmox_pbs else 'inactive' }}" title="Proxmox Backup Server">PB</span>
<span class="tech-icon {{ 'zabbix' if company.has_zabbix else 'inactive' }}" title="Zabbix Monitoring">Zb</span>
<span class="tech-icon {{ 'edr' if company.has_edr else 'inactive' }}" title="EDR / XDR">ED</span>
<span class="tech-icon {{ 'dr' if company.has_dr_plan else 'inactive' }}" title="Plan DR">DR</span>
</div>
{% else %}
<span class="text-muted">-</span>
@ -1332,16 +1333,20 @@
</td>
<td>
<div class="action-buttons">
<a href="{{ url_for('company_detail', company_id=company.id) }}" class="btn-icon" title="Zobacz profil">
<a href="{{ url_for('company_detail', slug=company.slug) }}" class="btn-icon" title="Zobacz profil">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('it_audit_form', company_id=company.id) }}" class="btn-icon edit" title="Edytuj audyt">
<a href="{{ url_for('it_audit_form', company_id=company.id) }}" class="btn-icon edit" title="{{ 'Edytuj audyt' if has_audit else 'Utwórz audyt' }}">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{% if has_audit %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
{% else %}
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
{% endif %}
</svg>
</a>
{% endif %}