feat: products page
Browse files- app.py +2 -0
- models.py +1 -0
- templates/base.html +7 -1
- templates/products.html +76 -0
- web/products.py +55 -0
app.py
CHANGED
|
@@ -59,6 +59,7 @@ with app.app_context():
|
|
| 59 |
from api.login import login_bp
|
| 60 |
from api.syslog import syslog_bp
|
| 61 |
from web.routes import web_bp
|
|
|
|
| 62 |
from api.webhooks import webhooks_bp
|
| 63 |
from web.events import events_bp
|
| 64 |
|
|
@@ -67,4 +68,5 @@ with app.app_context():
|
|
| 67 |
app.register_blueprint(syslog_bp, url_prefix='/')
|
| 68 |
app.register_blueprint(webhooks_bp, url_prefix='/api')
|
| 69 |
app.register_blueprint(web_bp)
|
|
|
|
| 70 |
app.register_blueprint(events_bp)
|
|
|
|
| 59 |
from api.login import login_bp
|
| 60 |
from api.syslog import syslog_bp
|
| 61 |
from web.routes import web_bp
|
| 62 |
+
from web.products import bp as products_bp
|
| 63 |
from api.webhooks import webhooks_bp
|
| 64 |
from web.events import events_bp
|
| 65 |
|
|
|
|
| 68 |
app.register_blueprint(syslog_bp, url_prefix='/')
|
| 69 |
app.register_blueprint(webhooks_bp, url_prefix='/api')
|
| 70 |
app.register_blueprint(web_bp)
|
| 71 |
+
app.register_blueprint(products_bp)
|
| 72 |
app.register_blueprint(events_bp)
|
models.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
from app import db
|
|
|
|
| 2 |
from datetime import datetime, timedelta
|
| 3 |
import secrets
|
| 4 |
from sqlalchemy import JSON, Text, Boolean, Integer, String, DateTime, ForeignKey
|
|
|
|
| 1 |
from app import db
|
| 2 |
+
from sqlalchemy import func
|
| 3 |
from datetime import datetime, timedelta
|
| 4 |
import secrets
|
| 5 |
from sqlalchemy import JSON, Text, Boolean, Integer, String, DateTime, ForeignKey
|
templates/base.html
CHANGED
|
@@ -41,7 +41,13 @@
|
|
| 41 |
Dashboard
|
| 42 |
</a>
|
| 43 |
</li>
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
<a class="nav-link" href="{{ url_for('web.policy_results') }}">
|
| 46 |
<i data-feather="shield" class="me-1"></i>
|
| 47 |
Policy
|
|
|
|
| 41 |
Dashboard
|
| 42 |
</a>
|
| 43 |
</li>
|
| 44 |
+
<li class="nav-item">
|
| 45 |
+
<a class="nav-link" href="{{ url_for('products.products') }}">
|
| 46 |
+
<i data-feather="package" class="me-1"></i>
|
| 47 |
+
Products
|
| 48 |
+
</a>
|
| 49 |
+
</li>
|
| 50 |
+
<li class="nav-item">
|
| 51 |
<a class="nav-link" href="{{ url_for('web.policy_results') }}">
|
| 52 |
<i data-feather="shield" class="me-1"></i>
|
| 53 |
Policy
|
templates/products.html
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{% extends "base.html" %}
|
| 2 |
+
|
| 3 |
+
{% block title %}Products - Attestation Store{% endblock %}
|
| 4 |
+
|
| 5 |
+
{% block content %}
|
| 6 |
+
<div class="container my-4">
|
| 7 |
+
<div class="row mb-4">
|
| 8 |
+
<div class="col">
|
| 9 |
+
<h1 class="h2">
|
| 10 |
+
<i data-feather="package" class="me-2"></i>
|
| 11 |
+
Products
|
| 12 |
+
</h1>
|
| 13 |
+
<p class="text-muted">Browse and filter products, versions, attestations, and policy results</p>
|
| 14 |
+
</div>
|
| 15 |
+
</div>
|
| 16 |
+
|
| 17 |
+
<!-- Search and Filters -->
|
| 18 |
+
<div class="card mb-4">
|
| 19 |
+
<div class="card-body">
|
| 20 |
+
<form method="GET" class="row g-3">
|
| 21 |
+
<div class="col-md-4">
|
| 22 |
+
<input type="text" class="form-control" name="filter" placeholder="Search products..." value="{{ filter_query }}">
|
| 23 |
+
</div>
|
| 24 |
+
<div class="col-md-2">
|
| 25 |
+
<button type="submit" class="btn btn-primary w-100">
|
| 26 |
+
<i data-feather="filter" class="me-1"></i> Filter
|
| 27 |
+
</button>
|
| 28 |
+
</div>
|
| 29 |
+
</form>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
|
| 33 |
+
<div class="card">
|
| 34 |
+
<div class="card-body p-0">
|
| 35 |
+
<div class="table-responsive">
|
| 36 |
+
<table class="table table-hover align-middle mb-0">
|
| 37 |
+
<thead class="table-dark">
|
| 38 |
+
<tr>
|
| 39 |
+
<th>Product</th>
|
| 40 |
+
<th>Version</th>
|
| 41 |
+
<th class="text-center">#Attestations</th>
|
| 42 |
+
<th class="text-center">Policy Results</th>
|
| 43 |
+
<th class="text-center">Actions</th>
|
| 44 |
+
</tr>
|
| 45 |
+
</thead>
|
| 46 |
+
<tbody>
|
| 47 |
+
{% for p in products %}
|
| 48 |
+
<tr>
|
| 49 |
+
<td>{{ p.product }}</td>
|
| 50 |
+
<td>{{ p.version }}</td>
|
| 51 |
+
<td class="text-center fw-bold">{{ p.attestations }}</td>
|
| 52 |
+
<td class="text-center">
|
| 53 |
+
{% if p.pass_count > 0 %}
|
| 54 |
+
<span class="badge bg-success">Pass: {{ p.pass_count }}</span>
|
| 55 |
+
{% endif %}
|
| 56 |
+
{% if p.non_pass_count > 0 %}
|
| 57 |
+
<span class="badge bg-danger ms-2">Non-pass: {{ p.non_pass_count }}</span>
|
| 58 |
+
{% endif %}
|
| 59 |
+
</td>
|
| 60 |
+
<td class="text-center">
|
| 61 |
+
<a href="/products/{{ p.product }}/attestations" class="btn btn-sm btn-outline-primary me-1">
|
| 62 |
+
<i data-feather="file-text" class="me-1"></i> Attestations
|
| 63 |
+
</a>
|
| 64 |
+
<a href="/products/{{ p.product }}/policy_results" class="btn btn-sm btn-outline-info">
|
| 65 |
+
<i data-feather="shield" class="me-1"></i> Policy Results
|
| 66 |
+
</a>
|
| 67 |
+
</td>
|
| 68 |
+
</tr>
|
| 69 |
+
{% endfor %}
|
| 70 |
+
</tbody>
|
| 71 |
+
</table>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
{% endblock %}
|
web/products.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, render_template, request, redirect, url_for
|
| 2 |
+
from models import db, Attestation, PolicyResult
|
| 3 |
+
from sqlalchemy import func
|
| 4 |
+
def get_products_with_stats(filter_query=None):
|
| 5 |
+
# Aggregate products and versions
|
| 6 |
+
query = db.session.query(
|
| 7 |
+
Attestation.product_key,
|
| 8 |
+
Attestation.product_version,
|
| 9 |
+
func.count(Attestation.id).label('attestations'),
|
| 10 |
+
).filter(Attestation.deleted == False)
|
| 11 |
+
if filter_query:
|
| 12 |
+
query = query.filter(Attestation.product_key.ilike(f'%{filter_query}%'))
|
| 13 |
+
query = query.group_by(Attestation.product_key, Attestation.product_version)
|
| 14 |
+
products = []
|
| 15 |
+
for product_key, product_version, attestations_count in query:
|
| 16 |
+
# Policy results aggregation
|
| 17 |
+
pass_count = db.session.query(func.count(PolicyResult.id)).filter(
|
| 18 |
+
PolicyResult.product_key == product_key,
|
| 19 |
+
PolicyResult.product_version == product_version,
|
| 20 |
+
PolicyResult.result_kind == 'pass',
|
| 21 |
+
PolicyResult.deleted == False
|
| 22 |
+
).scalar()
|
| 23 |
+
non_pass_count = db.session.query(func.count(PolicyResult.id)).filter(
|
| 24 |
+
PolicyResult.product_key == product_key,
|
| 25 |
+
PolicyResult.product_version == product_version,
|
| 26 |
+
PolicyResult.result_kind != 'pass',
|
| 27 |
+
PolicyResult.deleted == False
|
| 28 |
+
).scalar()
|
| 29 |
+
products.append({
|
| 30 |
+
'product': product_key,
|
| 31 |
+
'version': product_version,
|
| 32 |
+
'attestations': attestations_count,
|
| 33 |
+
'pass_count': pass_count,
|
| 34 |
+
'non_pass_count': non_pass_count
|
| 35 |
+
})
|
| 36 |
+
return products
|
| 37 |
+
|
| 38 |
+
bp = Blueprint('products', __name__)
|
| 39 |
+
|
| 40 |
+
@bp.route('/products')
|
| 41 |
+
def products():
|
| 42 |
+
# Get filter parameters from request
|
| 43 |
+
filter_query = request.args.get('filter', '')
|
| 44 |
+
products = get_products_with_stats(filter_query)
|
| 45 |
+
return render_template('products.html', products=products, filter_query=filter_query)
|
| 46 |
+
|
| 47 |
+
@bp.route('/products/<product>/attestations')
|
| 48 |
+
def product_attestations(product):
|
| 49 |
+
# Redirect to filtered attestations page
|
| 50 |
+
return redirect(url_for('web.attestations', product=product))
|
| 51 |
+
|
| 52 |
+
@bp.route('/products/<product>/policy_results')
|
| 53 |
+
def product_policy_results(product):
|
| 54 |
+
# Redirect to filtered policy results page
|
| 55 |
+
return redirect(url_for('web.policy_results', product=product))
|