whatdidyoudo 0.1.13__tar.gz → 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of whatdidyoudo might be problematic. Click here for more details.
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/PKG-INFO +1 -1
- whatdidyoudo-0.2.4/whatdidyoudo/app.py +146 -0
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/whatdidyoudo/static/style.css +63 -3
- whatdidyoudo-0.2.4/whatdidyoudo/templates/form.html +213 -0
- whatdidyoudo-0.1.13/whatdidyoudo/app.py +0 -106
- whatdidyoudo-0.1.13/whatdidyoudo/templates/form.html +0 -95
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/.gitignore +0 -0
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/README.md +0 -0
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/pyproject.toml +0 -0
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/whatdidyoudo/__init__.py +0 -0
- {whatdidyoudo-0.1.13 → whatdidyoudo-0.2.4}/whatdidyoudo/static/favicon.svg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: whatdidyoudo
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: A minimal Flask app that shows the amount of OpenStreetMap changes made by a user on a day.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rompe/whatdidyoudo
|
|
6
6
|
Project-URL: Issues, https://github.com/rompe/whatdidyoudo/issues
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""A Flask app that shows OSM tasks done by a user on a specific day."""
|
|
2
|
+
import datetime
|
|
3
|
+
import xml.etree.ElementTree as ET
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
import requests
|
|
7
|
+
|
|
8
|
+
from flask import Flask, render_template, request
|
|
9
|
+
from flask_caching import Cache
|
|
10
|
+
from flask_limiter import Limiter, RateLimitExceeded
|
|
11
|
+
from flask_limiter.util import get_remote_address
|
|
12
|
+
|
|
13
|
+
__version__ = "0.2.4"
|
|
14
|
+
|
|
15
|
+
app = Flask(__name__)
|
|
16
|
+
cache = Cache(app, config={"CACHE_TYPE": "SimpleCache",
|
|
17
|
+
"CACHE_DEFAULT_TIMEOUT": 60 * 60 * 24 * 7})
|
|
18
|
+
limiter = Limiter(app=app, key_func=get_remote_address)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class Changes:
|
|
23
|
+
"""Represent changes made by a user."""
|
|
24
|
+
changes: int = 0
|
|
25
|
+
changesets: int = 0
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_etree_from_url(url: str) -> ET.Element:
|
|
29
|
+
"""Fetches XML content from a URL and returns the root Element."""
|
|
30
|
+
response = requests.get(url, timeout=120)
|
|
31
|
+
response.raise_for_status() # Raise an error for bad responses
|
|
32
|
+
return ET.fromstring(response.content)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_changes(user: str, start_date: str, end_date: str | None = None,
|
|
36
|
+
start_time: str = "00:00", end_time: str = "23:59"):
|
|
37
|
+
"""Return a ({app: Changes}, [changeset_ids]) tuple for a date/time range.
|
|
38
|
+
|
|
39
|
+
start_date, end_date are ISO date strings (YYYY-MM-DD). start_time and
|
|
40
|
+
end_time are HH:MM strings. The function builds ISO datetimes for the OSM
|
|
41
|
+
API (UTC, appended with Z).
|
|
42
|
+
"""
|
|
43
|
+
changes: defaultdict[str, Changes] = defaultdict(Changes)
|
|
44
|
+
changeset_ids: list[str] = []
|
|
45
|
+
# Ensure end_date defaults to start_date when not provided
|
|
46
|
+
end_date = end_date or start_date
|
|
47
|
+
# Build ISO datetime strings expected by the OSM API
|
|
48
|
+
# e.g. 2025-10-24T00:00:00Z
|
|
49
|
+
start_time_iso = f"{start_date}T{start_time}:00Z"
|
|
50
|
+
end_time_iso = f"{end_date}T{end_time}:00Z"
|
|
51
|
+
|
|
52
|
+
changeset_url = ("https://api.openstreetmap.org/api/0.6/changesets?"
|
|
53
|
+
f"display_name={user}&"
|
|
54
|
+
f"time={start_time_iso},{end_time_iso}")
|
|
55
|
+
root = get_etree_from_url(url=changeset_url)
|
|
56
|
+
|
|
57
|
+
for cs in root.findall("changeset"):
|
|
58
|
+
cs_id = cs.attrib["id"]
|
|
59
|
+
changeset_ids.append(cs_id)
|
|
60
|
+
|
|
61
|
+
tags = {tag.attrib["k"]: tag.attrib["v"] for tag in cs.findall("tag")}
|
|
62
|
+
editor = tags.get("created_by", "")
|
|
63
|
+
changes[editor].changesets += 1
|
|
64
|
+
|
|
65
|
+
diff_url = ("https://api.openstreetmap.org/api/0.6/changeset/"
|
|
66
|
+
f"{cs_id}/download")
|
|
67
|
+
try:
|
|
68
|
+
root = get_etree_from_url(url=diff_url)
|
|
69
|
+
except requests.HTTPError:
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
for action in root:
|
|
73
|
+
changes[editor].changes += len(action)
|
|
74
|
+
return changes, changeset_ids
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.route('/')
|
|
78
|
+
@app.route('/<user>/')
|
|
79
|
+
@app.route('/<user>')
|
|
80
|
+
@app.route('/<user>/<start_date>/')
|
|
81
|
+
@app.route('/<user>/<start_date>')
|
|
82
|
+
@app.route('/<user>/<start_date>/<end_date>')
|
|
83
|
+
def whatdidyoudo(user: str | None = None, start_date: str | None = None,
|
|
84
|
+
end_date: str | None = None) -> str:
|
|
85
|
+
"""shows OSM tasks done by a user within a date/time range.
|
|
86
|
+
|
|
87
|
+
Expert mode allows finer-grained start/end times via query params:
|
|
88
|
+
expert=1, start_time=HH:MM, end_time=HH:MM
|
|
89
|
+
"""
|
|
90
|
+
changes: defaultdict[str, defaultdict[str, Changes]] = \
|
|
91
|
+
defaultdict(lambda: defaultdict(Changes))
|
|
92
|
+
changesets: dict[str, int] = {}
|
|
93
|
+
errors: list[str] = []
|
|
94
|
+
# Read expert mode and time params from query string
|
|
95
|
+
expert = request.args.get('expert', '0') in ('1', 'true', 'True')
|
|
96
|
+
start_time = request.args.get('start_time', '00:00')
|
|
97
|
+
end_time = request.args.get('end_time', '23:59')
|
|
98
|
+
|
|
99
|
+
date_str = (f"between {start_date} {start_time} and {end_date} {end_time}"
|
|
100
|
+
if end_date or expert else f"on {start_date}")
|
|
101
|
+
today = datetime.date.today().isoformat()
|
|
102
|
+
|
|
103
|
+
changeset_ids: list[str] = []
|
|
104
|
+
for name in [item.strip() for item in (user or "").split(",")
|
|
105
|
+
if item.strip()]:
|
|
106
|
+
cache_key = (f"changes_{name}_{start_date}_{end_date}_"
|
|
107
|
+
f"{start_time}_{end_time}")
|
|
108
|
+
cur_data = cache.get(cache_key) # type: ignore
|
|
109
|
+
if not cur_data:
|
|
110
|
+
try:
|
|
111
|
+
with limiter.limit("10 per minute"):
|
|
112
|
+
cur_data = get_changes(user=name,
|
|
113
|
+
start_date=start_date or today,
|
|
114
|
+
end_date=end_date,
|
|
115
|
+
start_time=start_time,
|
|
116
|
+
end_time=end_time)
|
|
117
|
+
# Only cache results when the range does not include today
|
|
118
|
+
if ((start_date and start_date != today) or
|
|
119
|
+
(end_date and end_date != today)):
|
|
120
|
+
cache.set(cache_key, cur_data) # type: ignore
|
|
121
|
+
except requests.HTTPError:
|
|
122
|
+
errors.append(
|
|
123
|
+
f"Can't determine changes for user {name} {date_str}.")
|
|
124
|
+
except RateLimitExceeded as msg:
|
|
125
|
+
errors.append("Rate limit exceeded while processing user "
|
|
126
|
+
f"{name}: {msg}")
|
|
127
|
+
if cur_data:
|
|
128
|
+
cur_changes, cur_ids = cur_data
|
|
129
|
+
changes[name] = cur_changes
|
|
130
|
+
changeset_ids.extend(cur_ids)
|
|
131
|
+
|
|
132
|
+
return render_template('form.html', user=user, start_date=start_date,
|
|
133
|
+
end_date=end_date, start_time=start_time,
|
|
134
|
+
end_time=end_time, expert=expert,
|
|
135
|
+
changes=changes, changesets=changesets,
|
|
136
|
+
errors=errors, date_str=date_str,
|
|
137
|
+
version=__version__, changeset_ids=changeset_ids)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main():
|
|
141
|
+
"""Run in debug mode."""
|
|
142
|
+
app.run(host="0.0.0.0", debug=True)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
if __name__ == "__main__":
|
|
146
|
+
main()
|
|
@@ -16,7 +16,7 @@ body {
|
|
|
16
16
|
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
17
17
|
padding: 2em 2.5em 1.5em 2.5em;
|
|
18
18
|
margin-top: 2em;
|
|
19
|
-
max-width:
|
|
19
|
+
max-width: 800px;
|
|
20
20
|
width: 95%;
|
|
21
21
|
}
|
|
22
22
|
|
|
@@ -37,12 +37,61 @@ form {
|
|
|
37
37
|
width: 100%;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
.expert-toggle {
|
|
41
|
+
display: flex;
|
|
42
|
+
align-items: center; /* vertically center checkbox and label within the form's row */
|
|
43
|
+
margin-top: 0.5em;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@media (max-width: 600px) {
|
|
47
|
+
.expert-toggle {
|
|
48
|
+
margin-top: 0; /* in stacked layout keep normal spacing */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Expert rows: align labels and inputs into two columns so inputs stack vertically
|
|
53
|
+
and are perfectly aligned regardless of label width. */
|
|
54
|
+
.expert-row {
|
|
55
|
+
/* allow the element to not introduce its own box so its children
|
|
56
|
+
participate in the parent's grid; keeps markup simple while
|
|
57
|
+
allowing a single shared grid column sizing for labels */
|
|
58
|
+
display: contents;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#expertControls {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-columns: auto 1fr; /* dynamic label column, flexible input column */
|
|
64
|
+
gap: 0.5em 1em;
|
|
65
|
+
align-items: center;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#expertControls label {
|
|
69
|
+
justify-self: end; /* right-align labels to create a neat gutter */
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
#expertControls input[type="datetime-local"] {
|
|
73
|
+
width: 100%;
|
|
74
|
+
min-width: 0; /* allow the input to shrink inside grid */
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* On small screens, stack label above input for readability */
|
|
78
|
+
@media (max-width: 600px) {
|
|
79
|
+
#expertControls {
|
|
80
|
+
grid-template-columns: 1fr; /* single column: label above input */
|
|
81
|
+
}
|
|
82
|
+
#expertControls label {
|
|
83
|
+
justify-self: start;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
label.hidden {
|
|
41
88
|
display: none;
|
|
42
89
|
}
|
|
43
90
|
|
|
44
91
|
input[type="text"],
|
|
45
92
|
input[type="date"],
|
|
93
|
+
input[type="datetime-local"],
|
|
94
|
+
input[type="checkbox"],
|
|
46
95
|
button {
|
|
47
96
|
border: none;
|
|
48
97
|
border-radius: 8px;
|
|
@@ -61,15 +110,25 @@ input[type="text"] {
|
|
|
61
110
|
color: #f3f3f3;
|
|
62
111
|
}
|
|
63
112
|
|
|
113
|
+
input[type="datetime-local"],
|
|
64
114
|
input[type="date"] {
|
|
65
115
|
/* flex: 1 1 120px; */
|
|
66
116
|
padding: 0.7em 0.5em;
|
|
67
117
|
background: #2c2f34;
|
|
68
118
|
color: #f3f3f3;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
input[type="date"] {
|
|
69
122
|
min-width: 120px;
|
|
70
123
|
max-width: 160px;
|
|
71
124
|
}
|
|
72
125
|
|
|
126
|
+
input[type="datetime-local"] {
|
|
127
|
+
min-width: 120px;
|
|
128
|
+
max-width: 260px;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
73
132
|
button {
|
|
74
133
|
padding: 0.7em 1.5em;
|
|
75
134
|
background: #4f8cff;
|
|
@@ -124,7 +183,8 @@ a:hover {
|
|
|
124
183
|
flex-direction: column;
|
|
125
184
|
gap: 0.7em;
|
|
126
185
|
}
|
|
127
|
-
input[type="date"]
|
|
186
|
+
input[type="date"],
|
|
187
|
+
input[type="datetime-local"] {
|
|
128
188
|
width: 100%;
|
|
129
189
|
min-width: unset;
|
|
130
190
|
max-width: unset;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>What Did You Do?</title>
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
|
8
|
+
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
9
|
+
<script>
|
|
10
|
+
function isoToday() {
|
|
11
|
+
const t = new Date();
|
|
12
|
+
const yyyy = t.getFullYear();
|
|
13
|
+
const mm = String(t.getMonth() + 1).padStart(2, '0');
|
|
14
|
+
const dd = String(t.getDate()).padStart(2, '0');
|
|
15
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function initForm() {
|
|
19
|
+
const expert = document.getElementById('expert');
|
|
20
|
+
const expertControls = document.getElementById('expertControls');
|
|
21
|
+
const simpleRow = document.getElementById('simpleDateRow');
|
|
22
|
+
|
|
23
|
+
// Initialize dates/time defaults
|
|
24
|
+
const single = document.getElementById('single_date');
|
|
25
|
+
const start_dt = document.getElementById('start_dt');
|
|
26
|
+
const end_dt = document.getElementById('end_dt');
|
|
27
|
+
|
|
28
|
+
if (single && !single.value) single.value = isoToday();
|
|
29
|
+
// default datetime-local values: YYYY-MM-DDTHH:MM
|
|
30
|
+
if (start_dt && !start_dt.value) start_dt.value = (single ? single.value : isoToday()) + 'T00:00';
|
|
31
|
+
if (end_dt && !end_dt.value) end_dt.value = (single ? single.value : isoToday()) + 'T23:59';
|
|
32
|
+
|
|
33
|
+
// If server rendered the expert checkbox checked, reflect that in UI
|
|
34
|
+
if (expert.checked) {
|
|
35
|
+
expertControls.style.display = '';
|
|
36
|
+
simpleRow.style.display = 'none';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
expert.addEventListener('change', function() {
|
|
40
|
+
if (expert.checked) {
|
|
41
|
+
expertControls.style.display = '';
|
|
42
|
+
simpleRow.style.display = 'none';
|
|
43
|
+
// copy single date into start/end if empty
|
|
44
|
+
if (start_dt && !start_dt.value) start_dt.value = (single ? single.value : isoToday()) + 'T00:00';
|
|
45
|
+
if (end_dt && !end_dt.value) end_dt.value = (single ? single.value : isoToday()) + 'T23:59';
|
|
46
|
+
} else {
|
|
47
|
+
expertControls.style.display = 'none';
|
|
48
|
+
simpleRow.style.display = '';
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function submitForm() {
|
|
54
|
+
const user = encodeURIComponent(document.getElementById('user').value);
|
|
55
|
+
const expert = document.getElementById('expert').checked;
|
|
56
|
+
if (!expert) {
|
|
57
|
+
const date = encodeURIComponent(document.getElementById('single_date').value);
|
|
58
|
+
// keep URL as /user/date
|
|
59
|
+
window.location.href = '/' + user + '/' + date;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// expert mode: use datetime-local inputs. We will split them into date and time
|
|
63
|
+
const sd_raw = document.getElementById('start_dt').value; // YYYY-MM-DDTHH:MM
|
|
64
|
+
const ed_raw = document.getElementById('end_dt').value || sd_raw;
|
|
65
|
+
if (!sd_raw) {
|
|
66
|
+
alert('Please provide a start date/time.');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const [sd_date, sd_time] = sd_raw.split('T');
|
|
70
|
+
const [ed_date, ed_time] = ed_raw.split('T');
|
|
71
|
+
const qs = `?expert=1&start_time=${encodeURIComponent(sd_time)}&end_time=${encodeURIComponent(ed_time)}`;
|
|
72
|
+
window.location.href = '/' + user + '/' + encodeURIComponent(sd_date) + '/' + encodeURIComponent(ed_date) + qs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
window.addEventListener('DOMContentLoaded', initForm);
|
|
76
|
+
</script>
|
|
77
|
+
</head>
|
|
78
|
+
<body>
|
|
79
|
+
<div class="container">
|
|
80
|
+
<h2><a href="/">What Did You Do?</a></h2>
|
|
81
|
+
<form id="mainForm" method="get" action="" onsubmit="event.preventDefault(); submitForm();">
|
|
82
|
+
<label for="user" class="hidden">User:</label>
|
|
83
|
+
<input type="text" id="user" name="user" value="{{ user or '' }}" placeholder="insert OSM username" required>
|
|
84
|
+
|
|
85
|
+
<!-- Simple date (default) -->
|
|
86
|
+
<div id="simpleDateRow">
|
|
87
|
+
<label for="single_date" class="hidden">Date:</label>
|
|
88
|
+
<input type="date" id="single_date" name="single_date" value="{{ start_date or '' }}">
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<!-- Expert controls (hidden by default) -->
|
|
92
|
+
<div id="expertControls" style="display: none; margin-top: 0.5em;">
|
|
93
|
+
<div class="expert-row">
|
|
94
|
+
<label for="start_dt">Start:</label>
|
|
95
|
+
<input type="datetime-local" id="start_dt" name="start_dt"
|
|
96
|
+
value="{{ (start_date ~ 'T' ~ start_time) if start_date else '' }}">
|
|
97
|
+
</div>
|
|
98
|
+
<div class="expert-row">
|
|
99
|
+
<label for="end_dt">End:</label>
|
|
100
|
+
<input type="datetime-local" id="end_dt" name="end_dt"
|
|
101
|
+
value="{{ ((end_date or start_date) ~ 'T' ~ (end_time or '23:59')) if (end_date or start_date) else '' }}">
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
|
|
105
|
+
<div class="expert-toggle">
|
|
106
|
+
<input type="checkbox" id="expert" name="expert"
|
|
107
|
+
value="1" {{ 'checked' if expert else '' }}>
|
|
108
|
+
<label for="expert">Expert mode</label>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<button type="submit">Show</button>
|
|
112
|
+
</form>
|
|
113
|
+
{% if user %}
|
|
114
|
+
{% for error in errors %}
|
|
115
|
+
<p class=error>{{ error }}</p>
|
|
116
|
+
{% endfor %}
|
|
117
|
+
{% for name, apps in changes.items() %}
|
|
118
|
+
{% set ns = namespace(changes=0, changesets=0) %}
|
|
119
|
+
{% for Change in apps.values() %}
|
|
120
|
+
{% set ns.changes = ns.changes + Change.changes %}
|
|
121
|
+
{% set ns.changesets = ns.changesets + Change.changesets %}
|
|
122
|
+
{% endfor %}
|
|
123
|
+
<ul>
|
|
124
|
+
<p><b><a href="https://www.openstreetmap.org/user/{{ name }}">{{ name }}</a></b>
|
|
125
|
+
did <b>{{ ns.changes }} change{{ "s" if ns.changes > 1 else "" }}</b>
|
|
126
|
+
in {{ ns.changesets }} changeset{{ "s" if ns.changesets > 1 else "" }}
|
|
127
|
+
{{ date_str }}:</p>
|
|
128
|
+
{% for app, Change in apps.items() %}
|
|
129
|
+
<li>{{ Change.changes }} change{{ "s" if Change.changes > 1 else "" }} in
|
|
130
|
+
{{ Change.changesets }} changeset{{ "s" if Change.changesets > 1 else "" }}
|
|
131
|
+
with {{ app }}</li>
|
|
132
|
+
{% endfor %}
|
|
133
|
+
</ul>
|
|
134
|
+
{% endfor %}
|
|
135
|
+
{% else %}
|
|
136
|
+
<p>
|
|
137
|
+
How much did you contribute to <a href="https://www.openstreetmap.org/">OpenStreetMap</a> today? Or any other day?
|
|
138
|
+
</p>
|
|
139
|
+
<p>
|
|
140
|
+
To answer this question, enter your OSM username and the date you want to check above and click "Show".
|
|
141
|
+
</p>
|
|
142
|
+
<div class="note">
|
|
143
|
+
<h4>Tips</h4>
|
|
144
|
+
<ul>
|
|
145
|
+
<li>Do you want to have a bookmark that always shows your numbers for the current day?
|
|
146
|
+
Just use <code>/YOUR_USERNAME</code> as URL and omit the date!
|
|
147
|
+
</li>
|
|
148
|
+
<li>You can enter multiple usernames separated by commas to see their contributions at once.
|
|
149
|
+
Try this after a mapwalk with friends!
|
|
150
|
+
</li>
|
|
151
|
+
</ul>
|
|
152
|
+
</div>
|
|
153
|
+
<div class="disclaimer">
|
|
154
|
+
<h4>Disclaimers</h4>
|
|
155
|
+
<ul>
|
|
156
|
+
<li>Quality beats quantity. While it's nice to see big numbers here, please remember
|
|
157
|
+
that just one high-effort change will help more that any amount of mediocre changes.
|
|
158
|
+
</li>
|
|
159
|
+
<li>This service is rate-limited to 10 OSM API requests per minute and caches results
|
|
160
|
+
for 1 hour (except for the current day) to reduce load on the API.
|
|
161
|
+
Too restrictive? Tell me!
|
|
162
|
+
</li>
|
|
163
|
+
<li>This is a very simple service. It does exactly one job and tries to do it good.
|
|
164
|
+
The data displayed here is shown by many other services, but I didn't find one
|
|
165
|
+
that answers my question in this way with just a simple bookmark.
|
|
166
|
+
Suggestions are welcome! Get in touch via the issue tracker on the project page.
|
|
167
|
+
</li>
|
|
168
|
+
</ul>
|
|
169
|
+
</div>
|
|
170
|
+
{% endif %}
|
|
171
|
+
{% if changeset_ids and changeset_ids|length > 0 %}
|
|
172
|
+
<div id="map" style="height: 300px; margin-top: 2em; border-radius: 12px; overflow: hidden;"></div>
|
|
173
|
+
<link rel="stylesheet" href="https://unpkg.com/leaflet/dist/leaflet.css" />
|
|
174
|
+
<script src="https://unpkg.com/leaflet/dist/leaflet.js"></script>
|
|
175
|
+
<script>
|
|
176
|
+
const changesetIds = JSON.parse('{{ changeset_ids|tojson|safe }}');
|
|
177
|
+
if (changesetIds.length > 0) {
|
|
178
|
+
const bounds = [];
|
|
179
|
+
Promise.all(
|
|
180
|
+
changesetIds.map(id =>
|
|
181
|
+
fetch(`https://api.openstreetmap.org/api/0.6/changeset/${id}`)
|
|
182
|
+
.then(r => r.text())
|
|
183
|
+
.then(xml => {
|
|
184
|
+
const parser = new DOMParser();
|
|
185
|
+
const doc = parser.parseFromString(xml, 'application/xml');
|
|
186
|
+
const cs = doc.querySelector('changeset');
|
|
187
|
+
if (cs) {
|
|
188
|
+
bounds.push([
|
|
189
|
+
[parseFloat(cs.getAttribute('min_lat')), parseFloat(cs.getAttribute('min_lon'))],
|
|
190
|
+
[parseFloat(cs.getAttribute('max_lat')), parseFloat(cs.getAttribute('max_lon'))]
|
|
191
|
+
]);
|
|
192
|
+
}
|
|
193
|
+
})
|
|
194
|
+
)
|
|
195
|
+
).then(() => {
|
|
196
|
+
if (bounds.length > 0) {
|
|
197
|
+
const map = L.map('map').setView([0, 0], 2);
|
|
198
|
+
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
199
|
+
maxZoom: 19,
|
|
200
|
+
attribution: '© OpenStreetMap contributors'
|
|
201
|
+
}).addTo(map);
|
|
202
|
+
const allBounds = L.latLngBounds(bounds.flat());
|
|
203
|
+
map.fitBounds(allBounds);
|
|
204
|
+
bounds.forEach(b => L.rectangle(b, {color: '#4f8cff', weight: 2}).addTo(map));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
</script>
|
|
209
|
+
{% endif %}
|
|
210
|
+
<p class="footer">Powered by <a href="https://github.com/rompe/whatdidyoudo">whatdidyoudo {{ version }}</a></p>
|
|
211
|
+
</div>
|
|
212
|
+
</body>
|
|
213
|
+
</html>
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
"""A Flask app that shows OSM tasks done by a user on a specific day."""
|
|
2
|
-
import datetime
|
|
3
|
-
import xml.etree.ElementTree as ET
|
|
4
|
-
from collections import defaultdict
|
|
5
|
-
from dataclasses import dataclass
|
|
6
|
-
import requests
|
|
7
|
-
|
|
8
|
-
from flask import Flask, render_template
|
|
9
|
-
from flask_caching import Cache
|
|
10
|
-
from flask_limiter import Limiter
|
|
11
|
-
from flask_limiter.util import get_remote_address
|
|
12
|
-
|
|
13
|
-
__version__ = "0.1.13"
|
|
14
|
-
|
|
15
|
-
app = Flask(__name__)
|
|
16
|
-
cache = Cache(app, config={"CACHE_TYPE": "SimpleCache",
|
|
17
|
-
"CACHE_DEFAULT_TIMEOUT": 60 * 60 * 24 * 7})
|
|
18
|
-
limiter = Limiter(app=app, key_func=get_remote_address)
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass
|
|
22
|
-
class Changes:
|
|
23
|
-
"""Represent changes made by a user."""
|
|
24
|
-
changes: int = 0
|
|
25
|
-
changesets: int = 0
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def get_etree_from_url(url: str) -> ET.Element:
|
|
29
|
-
"""Fetches XML content from a URL and returns the root Element."""
|
|
30
|
-
response = requests.get(url, timeout=120)
|
|
31
|
-
response.raise_for_status() # Raise an error for bad responses
|
|
32
|
-
return ET.fromstring(response.content)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def get_changes(user: str, date: str) -> defaultdict[str, Changes]:
|
|
36
|
-
"""Return a {app: Changes} dictionary."""
|
|
37
|
-
# {user: {app: Changes}}
|
|
38
|
-
changes: defaultdict[str, Changes] = defaultdict(Changes)
|
|
39
|
-
datetime_date = datetime.date.fromisoformat(date)
|
|
40
|
-
start_time = f"{datetime_date}T00:00:00Z"
|
|
41
|
-
end_time = f"{datetime_date + datetime.timedelta(days=1)}T00:00:00Z"
|
|
42
|
-
|
|
43
|
-
changeset_url = ("https://api.openstreetmap.org/api/0.6/changesets?"
|
|
44
|
-
f"display_name={user}&time={start_time},{end_time}")
|
|
45
|
-
root = get_etree_from_url(url=changeset_url)
|
|
46
|
-
|
|
47
|
-
for cs in root.findall("changeset"):
|
|
48
|
-
cs_id = cs.attrib["id"]
|
|
49
|
-
|
|
50
|
-
tags = {tag.attrib["k"]: tag.attrib["v"] for tag in cs.findall("tag")}
|
|
51
|
-
editor = tags.get("created_by", "")
|
|
52
|
-
changes[editor].changesets += 1
|
|
53
|
-
|
|
54
|
-
diff_url = ("https://api.openstreetmap.org/api/0.6/changeset/"
|
|
55
|
-
f"{cs_id}/download")
|
|
56
|
-
try:
|
|
57
|
-
root = get_etree_from_url(url=diff_url)
|
|
58
|
-
except requests.HTTPError:
|
|
59
|
-
continue
|
|
60
|
-
|
|
61
|
-
for action in root:
|
|
62
|
-
changes[editor].changes += len(action)
|
|
63
|
-
return changes
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@app.route('/')
|
|
67
|
-
@app.route('/<user>/')
|
|
68
|
-
@app.route('/<user>')
|
|
69
|
-
@app.route('/<user>/<date>')
|
|
70
|
-
def whatdidyoudo(user: str | None = None, date: str | None = None) -> str:
|
|
71
|
-
"""shows OSM tasks done by a user on a specific day."""
|
|
72
|
-
changes: defaultdict[str, defaultdict[str, Changes]] = \
|
|
73
|
-
defaultdict(lambda: defaultdict(Changes))
|
|
74
|
-
changesets: dict[str, int] = {}
|
|
75
|
-
errors: list[str] = []
|
|
76
|
-
today = datetime.date.today().isoformat()
|
|
77
|
-
if not date:
|
|
78
|
-
date = today
|
|
79
|
-
for name in [item.strip() for item in (user or "").split(",")
|
|
80
|
-
if item.strip()]:
|
|
81
|
-
cache_key = f"changes_{name}_{date}"
|
|
82
|
-
cur_changes = cache.get(cache_key) # type: ignore
|
|
83
|
-
if not cur_changes:
|
|
84
|
-
try:
|
|
85
|
-
with limiter.limit("10 per minute"):
|
|
86
|
-
cur_changes = get_changes(name, date)
|
|
87
|
-
if date != today:
|
|
88
|
-
cache.set(cache_key, cur_changes) # type: ignore
|
|
89
|
-
except requests.HTTPError:
|
|
90
|
-
errors.append(
|
|
91
|
-
f"Can't determine changes for user {name} on {date}.")
|
|
92
|
-
if cur_changes:
|
|
93
|
-
changes[name] = cur_changes
|
|
94
|
-
|
|
95
|
-
return render_template('form.html', user=user, date=date, changes=changes,
|
|
96
|
-
changesets=changesets, errors=errors,
|
|
97
|
-
version=__version__)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def main():
|
|
101
|
-
"""Run in debug mode."""
|
|
102
|
-
app.run(host="0.0.0.0", debug=True)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if __name__ == "__main__":
|
|
106
|
-
main()
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
<!DOCTYPE html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="UTF-8">
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
-
<title>What Did You Do?</title>
|
|
7
|
-
<link rel="icon" type="image/svg+xml" href="{{ url_for('static', filename='favicon.svg') }}">
|
|
8
|
-
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
|
9
|
-
<script>
|
|
10
|
-
window.onload = function() {
|
|
11
|
-
var dateInput = document.getElementById('date');
|
|
12
|
-
if (!dateInput.value) {
|
|
13
|
-
var today = new Date();
|
|
14
|
-
var yyyy = today.getFullYear();
|
|
15
|
-
var mm = String(today.getMonth() + 1).padStart(2, '0');
|
|
16
|
-
var dd = String(today.getDate()).padStart(2, '0');
|
|
17
|
-
dateInput.value = yyyy + '-' + mm + '-' + dd;
|
|
18
|
-
}
|
|
19
|
-
};
|
|
20
|
-
</script>
|
|
21
|
-
</head>
|
|
22
|
-
<body>
|
|
23
|
-
<div class="container">
|
|
24
|
-
<h2><a href="/">What Did You Do?</a></h2>
|
|
25
|
-
<form method="get" action="" onsubmit="event.preventDefault(); window.location.href='/' + encodeURIComponent(document.getElementById('user').value) + '/' + encodeURIComponent(document.getElementById('date').value);">
|
|
26
|
-
<label for="user">User:</label>
|
|
27
|
-
<input type="text" id="user" name="user" value="{{ user or '' }}" placeholder="insert OSM username" required>
|
|
28
|
-
|
|
29
|
-
<label for="date">Date:</label>
|
|
30
|
-
<input type="date" id="date" name="date" value="{{ date or '' }}" required>
|
|
31
|
-
|
|
32
|
-
<button type="submit">Show</button>
|
|
33
|
-
</form>
|
|
34
|
-
{% if user and date %}
|
|
35
|
-
{% for error in errors %}
|
|
36
|
-
<p class=error>{{ error }}</p>
|
|
37
|
-
{% endfor %}
|
|
38
|
-
{% for name, apps in changes.items() %}
|
|
39
|
-
{% set ns = namespace(changes=0, changesets=0) %}
|
|
40
|
-
{% for Change in apps.values() %}
|
|
41
|
-
{% set ns.changes = ns.changes + Change.changes %}
|
|
42
|
-
{% set ns.changesets = ns.changesets + Change.changesets %}
|
|
43
|
-
{% endfor %}
|
|
44
|
-
<ul>
|
|
45
|
-
<p><a href="https://www.openstreetmap.org/user/{{ user }}">{{ name }}</a>
|
|
46
|
-
did {{ ns.changes }} change{{ "s" if ns.changes > 1 else "" }}
|
|
47
|
-
in {{ ns.changesets }} changeset{{ "s" if ns.changesets > 1 else "" }}
|
|
48
|
-
on {{ date }}:</p>
|
|
49
|
-
{% for app, Change in apps.items() %}
|
|
50
|
-
<li>{{ Change.changes }} change{{ "s" if Change.changes > 1 else "" }} in
|
|
51
|
-
{{ Change.changesets }} changeset{{ "s" if Change.changesets > 1 else "" }}
|
|
52
|
-
with {{ app }}</li>
|
|
53
|
-
{% endfor %}
|
|
54
|
-
</ul>
|
|
55
|
-
{% endfor %}
|
|
56
|
-
{% else %}
|
|
57
|
-
<p>
|
|
58
|
-
How much did you contribute to <a href="https://www.openstreetmap.org/">OpenStreetMap</a> today? Or any other day?
|
|
59
|
-
</p>
|
|
60
|
-
<p>
|
|
61
|
-
To answer this question, enter your OSM username and the date you want to check above and click "Show".
|
|
62
|
-
</p>
|
|
63
|
-
<div class="note">
|
|
64
|
-
<h4>Tips</h4>
|
|
65
|
-
<ul>
|
|
66
|
-
<li>Do you want to have a bookmark that always shows your numbers for the current day?
|
|
67
|
-
Just use <code>/YOUR_USERNAME</code> as URL and omit the date!
|
|
68
|
-
</li>
|
|
69
|
-
<li>You can enter multiple usernames separated by commas to see their contributions at once.
|
|
70
|
-
Try this after a mapwalk with friends!
|
|
71
|
-
</li>
|
|
72
|
-
</ul>
|
|
73
|
-
</div>
|
|
74
|
-
<div class="disclaimer">
|
|
75
|
-
<h4>Disclaimers</h4>
|
|
76
|
-
<ul>
|
|
77
|
-
<li>Quality beats quantity. While it's nice to see big numbers here, please remember
|
|
78
|
-
that just one high-effort change will help more that any amount of mediocre changes.
|
|
79
|
-
</li>
|
|
80
|
-
<li>This service is rate-limited to 10 OSM API requests per minute and caches results
|
|
81
|
-
for 1 hour (except for the current day) to reduce load on the API.
|
|
82
|
-
Too restrictive? Tell me!
|
|
83
|
-
</li>
|
|
84
|
-
<li>This is a very simple service. It does exactly one job and tries to do it good.
|
|
85
|
-
The data displayed here is shown by many other services, but I didn't find one
|
|
86
|
-
that answers my question in this way with just a simple bookmark.
|
|
87
|
-
Suggestions are welcome! Get in touch via the issue tracker on the project page.
|
|
88
|
-
</li>
|
|
89
|
-
</ul>
|
|
90
|
-
</div>
|
|
91
|
-
{% endif %}
|
|
92
|
-
<p class="footer">Powered by <a href="https://github.com/rompe/whatdidyoudo">whatdidyoudo {{ version }}</a></p>
|
|
93
|
-
</div>
|
|
94
|
-
</body>
|
|
95
|
-
</html>
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|