webapp.py: sort the architecture drop down
[~helmut/crossqa.git] / webapp.py
1 #!/usr/bin/python3
2 # SPDX-License-Identifier: GPL-2.0+
3
4 import datetime
5 import lzma
6
7 import apt_pkg
8 apt_pkg.init()
9 version_compare = apt_pkg.version_compare
10
11 import flask
12 import flask_sqlalchemy
13 import jinja2
14 import sqlalchemy
15 import werkzeug
16
17 app = flask.Flask("crossqa")
18 app.config["SQLALCHEMY_DATABASE_URI"] = 'sqlite:///db'
19 app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
20 db = flask_sqlalchemy.SQLAlchemy(app)
21
22 src_template = """<!DOCTYPE html>
23 <html>
24  <head>
25   <title>{{ sourcepackage|e }} - Debian cross build</title>
26   <style>
27 tr.dep.bad td:nth-child(1) {
28     background-color: #faa;
29 }
30 tr.dep.tempbad td:nth-child(1) {
31     background-color: #ffa;
32 }
33 tr.dep.good td:nth-child(1) {
34     background-color: #afa;
35 }
36 tr.build.bad td:nth-child(4) {
37     background-color: #faa;
38 }
39 tr.build.tempbad td:nth-child(4) {
40     background-color: #ffa;
41 }
42 tr.build.good td:nth-child(4) {
43     background-color: #afa;
44 }
45 th {
46     padding-left: 1em;
47     padding-right: 1em;
48 }
49 td {
50     padding-left: 1px;
51     padding-right: 1em;
52 }
53 td:last-child {
54     padding-right: 1px;
55 }
56 footer {
57     margin-top: 3em;
58     border-top: 1px solid;
59 }
60   </style>
61  </head>
62  <body>
63   <header>
64    <h1>
65     <a href="https://tracker.debian.org/pkg/{{ sourcepackage|e }}">
66      {{- sourcepackage|e -}}
67     </a>
68    </h1>
69   </header>
70   <section>
71    <h3>Cross build dependency satisfiability</h3>
72    <table>
73     <thead>
74      <tr>
75       <th>state</th>
76       <th>architectures</th>
77      </tr>
78     </thead>
79     <tbody>
80      {%- set okarchs = depresult.pop(None, None) -%}
81      {%- for reason, archs in depresult.items()|sort -%}
82       <tr class="dep {{ "tempbad" if reason.startswith("skew") else "bad" }}">
83        <td>{{ reason|e }}</td>
84        <td>{{ archs|arch_format }}</td>
85       </tr>
86      {%- endfor -%}
87      {%- if okarchs -%}
88       <tr class="dep good">
89        <td>ok</td>
90        <td>{{ okarchs|arch_format }}</td>
91       </tr>
92      {%- endif -%}
93     </tbody>
94    </table>
95    <h5>See also</h5>
96    <ul>
97     {%- if show_bootstrapdn -%}
98      <li>
99       <a href="https://bootstrap.debian.net/cross_all/{{ sourcepackage|e }}.html">bootstrap.debian.net</a>
100      </li>
101     {%- endif -%}
102     <li>
103      <a href="https://qa.debian.org/dose/debcheck/cross_unstable_main_amd64/">debcheck</a>
104     </li>
105    </ul>
106   </section>
107   <section>
108    <h3>Cross builds</h3>
109    {%- if builds -%}
110     <table>
111      <thead>
112       <tr>
113        <th>started</th>
114        <th>version</th>
115        <th>architecture</th>
116        <th>result log</th>
117       </tr>
118      </thead>
119      <tbody>
120       {%- for build in builds|sort(attribute='starttime', reverse=true) -%}
121        <tr class="build {{ "good" if build.success else "bad" }}">
122         <td>
123          {{- build.starttime|sqltimestamp|formatts -}}
124         </td>
125         <td>{{ build.version|e }}</td>
126         <td>{{ build.architecture|e }}</td>
127         <td>
128          <a href="{{ url_for("show_log", filename=build.filename[:-3]) }}">
129           {{- "ok" if build.success else "failed" -}}
130          </a>
131          <a href="{{ url_for("show_log", filename=build.filename) }}">xz</a>
132         </td>
133        </tr>
134       {%- endfor -%}
135      </tbody>
136     </table>
137    {%- else -%}
138     <p>No build performed yet.</p>
139    {%- endif -%}
140    <form method="POST" action="{{ url_for("request_schedule")|e }}">
141     <input type="submit" name="schedule" value="cross build" />
142     {{ sourcepackage|e }} for
143     <input type="hidden" name="source" value="{{ sourcepackage|e }}" />
144     <select name="architecture">
145      <option value="any">any</option>
146      {%- for architecture in architectures|sort -%}
147       <option value="{{ architecture|e }}">{{ architecture|e }}</option>
148      {%- endfor -%}
149     </select>
150    </form>
151   </section>
152   <footer>
153    <h3>Details about this service</h3>
154    <ul>
155     <li>Maintainer: Helmut Grohne &lt;helmut@subdivi.de&gt;</li>
156     <li>Source: git://git.subdivi.de/~helmut/crossqa.git</li>
157    </ul>
158   </footer>
159  </body>
160 </html>
161 """
162
163 schedule_template = """<!DOCTYPE html>
164 <html>
165  <body>
166   <p>Scheduled a build of {{ request.form["source"]|e }}
167    {%- if request.form["architecture"] != "any" %}
168     for {{ request.form["architecture"]|e -}}
169    {%- endif %}.
170   <p>
171  </body>
172 </html>
173 """
174
175 @app.template_filter("sqltimestamp")
176 def sqltimestamp_filter(s):
177     strptime = datetime.datetime.strptime
178     try:
179         return strptime(s, "%Y-%m-%d %H:%M:%S.%f").replace(microsecond=0)
180     except ValueError:
181         return strptime(s, "%Y-%m-%d %H:%M:%S")
182
183
184 def formatts(ts):
185     assert isinstance(ts, datetime.datetime)
186     dt = datetime.datetime.utcnow() - ts
187     if dt < datetime.timedelta(seconds=1):
188         return "now"
189     if dt < datetime.timedelta(seconds=100):
190         return "%d s" % dt.seconds
191     if dt < datetime.timedelta(minutes=100):
192         return "%d m" % (dt.seconds // 60)
193     if dt < datetime.timedelta(days=1):
194         return "%d h" % (dt.seconds // (60 * 60))
195     return "%d d" % dt.days
196
197
198 @app.template_filter("formatts")
199 def formatts_filter(ts):
200     return jinja2.Markup('<time title="%s" datetime="%s">%s</time>' %
201                          (ts, ts, formatts(ts)))
202
203 @app.template_filter('arch_format')
204 @jinja2.contextfilter
205 def arch_format_filter(context, some_archs):
206     if context["architectures"] == some_archs:
207         return "any"
208     return ", ".join(sorted(some_archs))
209
210 def collect_depstate(conn, source):
211     version = None
212     depstate = None
213     query = sqlalchemy.text("""
214         SELECT version, architecture, satisfiable, reason
215             FROM depstate WHERE source = :source;""")
216     for row in conn.execute(query, source=source):
217         if version is None or version_compare(version, row.version) > 0:
218             version = row.version
219             depstate = {}
220         depstate[row.architecture] = None if row.satisfiable else row.reason
221     if version is None:
222         raise werkzeug.exceptions.NotFound()
223     depresult = {}
224     for arch, reason in depstate.items():
225         depresult.setdefault(reason, set()).add(arch)
226     return version, depresult
227
228 @app.route("/src/<source>")
229 def show_source(source):
230     context = dict(sourcepackage=source)
231     with db.engine.connect() as conn:
232         query = sqlalchemy.text("SELECT architecture FROM depcheck;")
233         context["architectures"] = set(row[0] for row in conn.execute(query))
234         context["version"], context["depresult"] = collect_depstate(conn,
235                                                                     source)
236         query = sqlalchemy.text("""
237             SELECT version, architecture, success, starttime, filename
238                 FROM builds WHERE source = :source;""")
239         context["builds"] = list(conn.execute(query, source=source))
240         context["show_bootstrapdn"] = \
241                 any(reason and not reason.startswith("skew ")
242                     for reason in context["depresult"].keys())
243     return flask.render_template_string(src_template, **context)
244
245 @app.route("/build/<path:filename>")
246 def show_log(filename):
247     if filename.endswith(".xz"):
248         return flask.send_from_directory("logs", filename,
249                                          mimetype="application/octet-stream")
250     filename = flask.safe_join("logs", filename + ".xz")
251     try:
252         return flask.send_file(lzma.open(filename, "rb"),
253                                mimetype="text/plain")
254     except FileNotFoundError:
255         raise werkzeug.exceptions.NotFound()
256
257
258 @app.route("/schedule", methods=["POST"])
259 def request_schedule():
260     source = flask.request.form["source"]
261     architecture = flask.request.form["architecture"]
262     with db.engine.connect() as conn:
263         query = sqlalchemy.text("""
264             SELECT 1 FROM depstate WHERE source = :source;""")
265         if not conn.execute(query, source=source).first():
266             raise werkzeug.exceptions.BadRequest()
267         if architecture == "any":
268             architecture = None
269         else:
270             query = sqlalchemy.text("""
271                 SELECT 1 FROM depcheck WHERE architecture = :architecture;""")
272             if not conn.execute(query, architecture=architecture).first():
273                 raise werkzeug.exceptions.BadRequest()
274         query = sqlalchemy.text("""
275             INSERT INTO buildrequests (source, architecture, requesttime)
276                 VALUES (:source, :architecture, datetime('now'));""")
277         conn.execute(query, source=source, architecture=architecture)
278     return flask.render_template_string(schedule_template)