mirror of
https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer.git
synced 2026-06-19 04:12:55 +02:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a935261e6 | |||
| a4e30bf334 | |||
| 56389e27d7 | |||
| c8d42e2459 | |||
| a6ebbce4cf | |||
| 88149fbb30 |
@@ -2,7 +2,7 @@
|
|||||||
import os
|
import os
|
||||||
import csv
|
import csv
|
||||||
import requests
|
import requests
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET # nosec
|
||||||
import time
|
import time
|
||||||
from qgis.core import QgsMessageLog, Qgis
|
from qgis.core import QgsMessageLog, Qgis
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ def fetch_set(internal_name, api_set, task=None):
|
|||||||
try:
|
try:
|
||||||
response = requests.get(BASE_URL, params=params, timeout=30)
|
response = requests.get(BASE_URL, params=params, timeout=30)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
root = ET.fromstring(response.content)
|
root = ET.fromstring(response.content) # nosec
|
||||||
|
|
||||||
records = root.findall('.//oai:record', NS)
|
records = root.findall('.//oai:record', NS)
|
||||||
for rec in records:
|
for rec in records:
|
||||||
|
|||||||
+168
-32
@@ -1,12 +1,11 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
import base64
|
|
||||||
from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
|
from qgis.PyQt.QtWidgets import (QDialog, QVBoxLayout,
|
||||||
QLineEdit, QDialogButtonBox,
|
QLineEdit, QDialogButtonBox,
|
||||||
QCheckBox, QGroupBox, QPushButton,
|
QCheckBox, QGroupBox, QPushButton,
|
||||||
QListWidget, QListWidgetItem, QHBoxLayout,
|
QListWidget, QListWidgetItem, QHBoxLayout,
|
||||||
QMessageBox, QLabel, QFormLayout)
|
QMessageBox, QLabel, QFormLayout)
|
||||||
from qgis.PyQt.QtCore import Qt, QSettings
|
from qgis.PyQt.QtCore import Qt, QSettings
|
||||||
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis
|
from qgis.core import QgsTask, QgsApplication, QgsMessageLog, Qgis, QgsAuthMethodConfig
|
||||||
from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
|
from .amcr_codelists import (OBDOBI, TYP_AKCE, KRAJE, AREAL, ORGANIZACE,
|
||||||
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY,
|
OKRESY, KATASTRY, VEDOUCI, PIAN_PRESNOST, TYP_LOKALITY,
|
||||||
DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST,
|
DRUH_LOKALITY, JISTOTA, LOKALITA_ZACHOVALOST, PRISTUPNOST,
|
||||||
@@ -352,12 +351,24 @@ class AmcrFilterDialog(QDialog):
|
|||||||
|
|
||||||
class LoginDialog(QDialog):
|
class LoginDialog(QDialog):
|
||||||
"""
|
"""
|
||||||
Dialog pro uložení přihlašovacích údajů do AMČR.
|
Dialog for saving AMČR login credentials securely in the QGIS Authentication Manager.
|
||||||
Ukládá do QSettings (username plaintext, heslo base64).
|
|
||||||
|
Credentials are encrypted by the platform's native secret storage
|
||||||
|
(DPAPI on Windows, Keychain on macOS, encrypted SQLite on Linux).
|
||||||
|
The auth config ID is persisted in QSettings so the session can be
|
||||||
|
restored automatically after a QGIS restart.
|
||||||
|
|
||||||
|
Note on QgsAuthManager quirks (QGIS 4 / Python bindings):
|
||||||
|
- hasConfigId() is unreliable – it checks an in-memory cache that may not
|
||||||
|
be populated yet. We never use it as a hard gate; we skip it and call
|
||||||
|
loadAuthenticationConfig() directly instead.
|
||||||
|
- storeAuthenticationConfig() and loadAuthenticationConfig() both have
|
||||||
|
SIP_INOUT on their config parameter, so Python bindings return a tuple
|
||||||
|
(bool, QgsAuthMethodConfig) rather than just bool. Always unpack both.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
KEY_USER = "amcr_viewer/username"
|
SETTINGS_KEY = "amcr_viewer/auth_config_id"
|
||||||
KEY_PASS = "amcr_viewer/password"
|
CONFIG_NAME = "AMČR Viewer"
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -366,14 +377,19 @@ class LoginDialog(QDialog):
|
|||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
|
|
||||||
settings = QSettings()
|
# Check whether a config ID is already stored from a previous session.
|
||||||
has_saved = bool(settings.value(self.KEY_USER, ""))
|
# We attempt a lightweight load (full=False) to confirm it is readable,
|
||||||
|
# since hasConfigId() may return False even for valid configs (cache lag).
|
||||||
|
existing_id = QSettings().value(self.SETTINGS_KEY, "")
|
||||||
|
self._has_saved = bool(existing_id) and bool(self._load_username_from_config(existing_id))
|
||||||
|
|
||||||
if has_saved:
|
if self._has_saved:
|
||||||
info = QLabel("✔ Přihlašovací údaje jsou uloženy. Vyplňte pole níže pro jejich změnu.")
|
info = QLabel("✔ Přihlašovací údaje jsou bezpečně uloženy ve správci autentizace QGIS.\n"
|
||||||
|
"Vyplňte pole níže pouze pokud je chcete změnit.")
|
||||||
info.setStyleSheet("color: green; font-style: italic;")
|
info.setStyleSheet("color: green; font-style: italic;")
|
||||||
else:
|
else:
|
||||||
info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\nVarování: přihlašovací údaje budou nezašifrovaně uloženy v registrech systému.")
|
info = QLabel("Zadejte přihlašovací údaje k Digitálnímu archivu AMČR.\n"
|
||||||
|
"Budou zašifrovaně uloženy ve správci autentizace QGIS.")
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
layout.addSpacing(8)
|
layout.addSpacing(8)
|
||||||
@@ -382,20 +398,23 @@ class LoginDialog(QDialog):
|
|||||||
|
|
||||||
self.txt_user = QLineEdit()
|
self.txt_user = QLineEdit()
|
||||||
self.txt_user.setPlaceholderText("např. jan.novak@email.cz")
|
self.txt_user.setPlaceholderText("např. jan.novak@email.cz")
|
||||||
# Předvyplnit uložené jméno pro pohodlí
|
# Pre-fill the stored username (not sensitive) for convenience
|
||||||
self.txt_user.setText(settings.value(self.KEY_USER, ""))
|
if self._has_saved:
|
||||||
|
self.txt_user.setText(self._load_username_from_config(existing_id))
|
||||||
form.addRow("E-mail:", self.txt_user)
|
form.addRow("E-mail:", self.txt_user)
|
||||||
|
|
||||||
self.txt_pass = QLineEdit()
|
self.txt_pass = QLineEdit()
|
||||||
self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password)
|
self.txt_pass.setEchoMode(QLineEdit.EchoMode.Password)
|
||||||
self.txt_pass.setPlaceholderText("heslo")
|
self.txt_pass.setPlaceholderText(
|
||||||
|
"ponechte prázdné pro zachování stávajícího hesla" if self._has_saved else "heslo"
|
||||||
|
)
|
||||||
form.addRow("Heslo:", self.txt_pass)
|
form.addRow("Heslo:", self.txt_pass)
|
||||||
|
|
||||||
layout.addLayout(form)
|
layout.addLayout(form)
|
||||||
layout.addSpacing(8)
|
layout.addSpacing(8)
|
||||||
|
|
||||||
if has_saved:
|
if self._has_saved:
|
||||||
btn_forget = QPushButton("Zapomenout uložené přihlašovací údaje")
|
btn_forget = QPushButton("Odebrat uložené přihlašovací údaje")
|
||||||
btn_forget.setStyleSheet("color: #c0392b;")
|
btn_forget.setStyleSheet("color: #c0392b;")
|
||||||
btn_forget.clicked.connect(self._forget_credentials)
|
btn_forget.clicked.connect(self._forget_credentials)
|
||||||
layout.addWidget(btn_forget)
|
layout.addWidget(btn_forget)
|
||||||
@@ -411,35 +430,152 @@ class LoginDialog(QDialog):
|
|||||||
|
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Private helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load_config(config_id: str, full: bool = False):
|
||||||
|
"""
|
||||||
|
Attempt to load a QgsAuthMethodConfig by ID.
|
||||||
|
Returns (ok, cfg). Never raises; returns (False, empty cfg) on any error.
|
||||||
|
full=True decrypts and includes the password.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
auth_mgr = QgsApplication.authManager()
|
||||||
|
cfg = QgsAuthMethodConfig()
|
||||||
|
result = auth_mgr.loadAuthenticationConfig(config_id, cfg, full)
|
||||||
|
# Python bindings return (bool, cfg) due to SIP_INOUT parameter
|
||||||
|
if isinstance(result, tuple):
|
||||||
|
return result
|
||||||
|
return result, cfg
|
||||||
|
except Exception:
|
||||||
|
return False, QgsAuthMethodConfig()
|
||||||
|
|
||||||
|
def _load_username_from_config(self, config_id: str) -> str:
|
||||||
|
"""Load just the username from a stored config (no password decryption)."""
|
||||||
|
ok, cfg = self._load_config(config_id, full=False)
|
||||||
|
return cfg.config("username", "") if ok else ""
|
||||||
|
|
||||||
|
def _ensure_master_password(self) -> bool:
|
||||||
|
"""
|
||||||
|
Ensure the Auth Manager is unlocked before writing.
|
||||||
|
Prompts the user to set or enter the master password if needed.
|
||||||
|
Returns True if the manager is ready, False if the user cancelled.
|
||||||
|
"""
|
||||||
|
auth_mgr = QgsApplication.authManager()
|
||||||
|
|
||||||
|
if auth_mgr.isDisabled():
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Správce autentizace nedostupný",
|
||||||
|
"Správce autentizace QGIS je zakázán nebo poškozený.\n"
|
||||||
|
"Zkuste obnovit databázi: Nastavení → Možnosti → Autentizace → Pomůcky."
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# setMasterPassword(True) shows the QGIS master password dialog if needed
|
||||||
|
if not auth_mgr.setMasterPassword(True):
|
||||||
|
return False # User cancelled the master password dialog
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Button actions
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _save_and_accept(self):
|
def _save_and_accept(self):
|
||||||
username = self.txt_user.text().strip()
|
username = self.txt_user.text().strip()
|
||||||
password = self.txt_pass.text()
|
password = self.txt_pass.text()
|
||||||
|
|
||||||
if not username or not password:
|
if not username:
|
||||||
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím uživatelské jméno i heslo.")
|
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím e-mailovou adresu.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
existing_id = QSettings().value(self.SETTINGS_KEY, "")
|
||||||
|
auth_mgr = QgsApplication.authManager()
|
||||||
|
|
||||||
|
# If a config already exists and the password field is blank,
|
||||||
|
# update only the username and keep the existing encrypted password.
|
||||||
|
if not password and existing_id:
|
||||||
|
ok, cfg = self._load_config(existing_id, full=True)
|
||||||
|
if ok:
|
||||||
|
if not self._ensure_master_password():
|
||||||
|
return
|
||||||
|
cfg.setConfig("username", username)
|
||||||
|
auth_mgr.updateAuthenticationConfig(cfg)
|
||||||
|
self.accept()
|
||||||
|
return
|
||||||
|
|
||||||
|
if not password:
|
||||||
|
QMessageBox.warning(self, "Chybí údaje", "Vyplňte prosím heslo.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._ensure_master_password():
|
||||||
|
return
|
||||||
|
|
||||||
|
cfg = QgsAuthMethodConfig()
|
||||||
|
cfg.setName(self.CONFIG_NAME)
|
||||||
|
cfg.setMethod("Basic")
|
||||||
|
cfg.setConfig("username", username)
|
||||||
|
cfg.setConfig("password", password) # nosec B106
|
||||||
|
|
||||||
settings = QSettings()
|
settings = QSettings()
|
||||||
settings.setValue(self.KEY_USER, username)
|
|
||||||
# base64 není šifrování, ale heslo aspoň neleží v plaintextu v registru
|
# Try to update an existing config first; fall back to creating a new one.
|
||||||
settings.setValue(self.KEY_PASS, base64.b64encode(password.encode()).decode())
|
# We skip hasConfigId() as it may return False despite the config existing
|
||||||
|
# (in-memory cache may not be populated yet in QGIS 4).
|
||||||
|
ok_load, existing_cfg = self._load_config(existing_id, full=False) if existing_id else (False, None)
|
||||||
|
if ok_load:
|
||||||
|
cfg.setId(existing_id)
|
||||||
|
ok, cfg = auth_mgr.updateAuthenticationConfig(cfg)
|
||||||
|
else:
|
||||||
|
ok, cfg = auth_mgr.storeAuthenticationConfig(cfg)
|
||||||
|
|
||||||
|
config_id = cfg.id() if cfg else ""
|
||||||
|
|
||||||
|
if not ok or not config_id:
|
||||||
|
QMessageBox.critical(
|
||||||
|
self, "Chyba uložení",
|
||||||
|
"Přihlašovací údaje se nepodařilo uložit do správce autentizace QGIS.\n"
|
||||||
|
"Zkuste restartovat QGIS a přihlásit se znovu."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
settings.setValue(self.SETTINGS_KEY, config_id)
|
||||||
self.accept()
|
self.accept()
|
||||||
|
|
||||||
def _forget_credentials(self):
|
def _forget_credentials(self):
|
||||||
settings = QSettings()
|
settings = QSettings()
|
||||||
settings.remove(self.KEY_USER)
|
existing_id = settings.value(self.SETTINGS_KEY, "")
|
||||||
settings.remove(self.KEY_PASS)
|
if existing_id:
|
||||||
QMessageBox.information(self, "Hotovo", "Přihlašovací údaje byly odstraněny.")
|
QgsApplication.authManager().removeAuthenticationConfig(existing_id)
|
||||||
|
settings.remove(self.SETTINGS_KEY)
|
||||||
|
QMessageBox.information(self, "Hotovo", "Uložené přihlašovací údaje byly odebrány.")
|
||||||
self.reject()
|
self.reject()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public static API – call this anywhere in the plugin to get credentials
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_credentials() -> tuple[str, str]:
|
def get_credentials() -> tuple[str, str]:
|
||||||
"""Vrátí (username, password) z QSettings, nebo ('', '') pokud nejsou uloženy."""
|
"""
|
||||||
|
Retrieve (username, password) from the QGIS Authentication Manager.
|
||||||
|
Returns ('', '') if no credentials are stored or the manager is locked.
|
||||||
|
|
||||||
|
Note: hasConfigId() is intentionally skipped here – it checks an
|
||||||
|
in-memory cache that may lag behind the actual database contents,
|
||||||
|
causing false negatives (see class docstring). loadAuthenticationConfig()
|
||||||
|
is called directly and its return value is used as the authoritative result.
|
||||||
|
"""
|
||||||
settings = QSettings()
|
settings = QSettings()
|
||||||
username = settings.value(LoginDialog.KEY_USER, "")
|
config_id = settings.value(LoginDialog.SETTINGS_KEY, "")
|
||||||
encoded = settings.value(LoginDialog.KEY_PASS, "")
|
|
||||||
try:
|
if not config_id:
|
||||||
password = base64.b64decode(encoded.encode()).decode() if encoded else ""
|
return "", ""
|
||||||
except Exception:
|
|
||||||
password = ""
|
ok, cfg = LoginDialog._load_config(config_id, full=True)
|
||||||
return username, password
|
if not ok:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
return cfg.config("username", ""), cfg.config("password", "") # nosec B106
|
||||||
@@ -142,6 +142,13 @@ def tr_code(code):
|
|||||||
return ""
|
return ""
|
||||||
return TRANSLATIONS.get(code, code)
|
return TRANSLATIONS.get(code, code)
|
||||||
|
|
||||||
|
def komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
|
||||||
|
if filter_areal and komp.get('komponenta_areal', {}).get('id', "") not in filters.get('f_areal', []):
|
||||||
|
return False
|
||||||
|
if filter_datace and komp.get('komponenta_obdobi', {}).get('id', "") not in filters.get('f_obdobi', []):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"):
|
def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false"):
|
||||||
"""
|
"""
|
||||||
Main processing function:
|
Main processing function:
|
||||||
@@ -204,6 +211,10 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
|
|||||||
# Check if we should skip negative results based on filter
|
# Check if we should skip negative results based on filter
|
||||||
skip_negativni = filters.get('posevidence') == 'true' if filters else False
|
skip_negativni = filters.get('posevidence') == 'true' if filters else False
|
||||||
|
|
||||||
|
# Check whether we should filter results based on component filters
|
||||||
|
filter_areal = "f_areal" in filters if filters else False
|
||||||
|
filter_datace = "f_obdobi" in filters if filters else False
|
||||||
|
|
||||||
# --- API PAGINATION LOOP ---
|
# --- API PAGINATION LOOP ---
|
||||||
while True:
|
while True:
|
||||||
base_params['rows'] = BATCH_DOCS
|
base_params['rows'] = BATCH_DOCS
|
||||||
@@ -339,6 +350,14 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
|
|||||||
if skip_negativni and dj.get('dj_negativni_jednotka') is True:
|
if skip_negativni and dj.get('dj_negativni_jednotka') is True:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
komps = dj.get('dj_komponenta', [])
|
||||||
|
|
||||||
|
if filter_areal or filter_datace:
|
||||||
|
if not komps:
|
||||||
|
continue
|
||||||
|
if not any(komp_projde_filtrem(komp, filter_areal, filter_datace, filters) for komp in komps):
|
||||||
|
continue
|
||||||
|
|
||||||
dj_id = dj.get('ident_cely')
|
dj_id = dj.get('ident_cely')
|
||||||
dj_typ = dj.get('dj_typ')
|
dj_typ = dj.get('dj_typ')
|
||||||
|
|
||||||
@@ -361,9 +380,11 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
|
|||||||
|
|
||||||
if komponenty == "true":
|
if komponenty == "true":
|
||||||
# One feature per component — all data on a single row, no relations needed
|
# One feature per component — all data on a single row, no relations needed
|
||||||
komps = dj.get('dj_komponenta', [])
|
|
||||||
if komps:
|
if komps:
|
||||||
for komp in komps:
|
for komp in komps:
|
||||||
|
if not komp_projde_filtrem(komp, filter_areal, filter_datace, filters):
|
||||||
|
continue
|
||||||
|
|
||||||
komp_meta = {
|
komp_meta = {
|
||||||
**dj_meta,
|
**dj_meta,
|
||||||
'komponenta_id': komp.get('ident_cely', ""),
|
'komponenta_id': komp.get('ident_cely', ""),
|
||||||
@@ -374,6 +395,9 @@ def load_amcr_data(canvas, bb, filters=None, typ_dat="akce", komponenty="false")
|
|||||||
target_pian_ids_count += 1
|
target_pian_ids_count += 1
|
||||||
else:
|
else:
|
||||||
# DJ without components — still include with empty component fields
|
# DJ without components — still include with empty component fields
|
||||||
|
if filter_areal or filter_datace:
|
||||||
|
continue
|
||||||
|
|
||||||
empty_meta = {
|
empty_meta = {
|
||||||
**dj_meta,
|
**dj_meta,
|
||||||
'komponenta_id': "",
|
'komponenta_id': "",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ name=AMČR Viewer
|
|||||||
qgisMinimumVersion=3.4.0
|
qgisMinimumVersion=3.4.0
|
||||||
qgisMaximumVersion=4.99.0
|
qgisMaximumVersion=4.99.0
|
||||||
description=Viewing and downloading the AMČR data.
|
description=Viewing and downloading the AMČR data.
|
||||||
version=2.0.0-alpha.1
|
version=2.0.0-alpha.4
|
||||||
author=David Spáčil
|
author=David Spáčil
|
||||||
email=spacil@arub.cz
|
email=spacil@arub.cz
|
||||||
|
|
||||||
@@ -22,7 +22,16 @@ repository=https://github.com/ARUP-CAS/aiscr-qgis-amcr-viewer
|
|||||||
|
|
||||||
hasProcessingProvider=no
|
hasProcessingProvider=no
|
||||||
# Uncomment the following line and add your changelog:
|
# Uncomment the following line and add your changelog:
|
||||||
# changelog=
|
changelog=
|
||||||
|
v2.0.0-alpha.4 (2026-06-03)
|
||||||
|
* Backend filtering of the results based on the component-related filters improvement (plugin not only loads the results from API, it filters them further)
|
||||||
|
v2.0.0-alpha.2–3 (2026-05-19)
|
||||||
|
* Security vulnerabilities fix
|
||||||
|
v2.0.0-alpha.1 (2026-05-19):
|
||||||
|
* Attribute fields renamed to be ASCII compliant
|
||||||
|
* Codelist update; codelist can be recompiled from AMČR API
|
||||||
|
* Basic element changed from Documentation Unit to Component if user asks for components to simplify result filtering
|
||||||
|
* Plugin now supports logging with an AMČR account and enables the downloading of Events and Sites available to the roles Researcher and higher
|
||||||
|
|
||||||
# Tags are comma separated with spaces allowed
|
# Tags are comma separated with spaces allowed
|
||||||
tags=python,AMCR,AIS CR,archaeology,PIAN,AMČR
|
tags=python,AMCR,AIS CR,archaeology,PIAN,AMČR
|
||||||
|
|||||||
Reference in New Issue
Block a user