Add QRZCALL.EU as a QSO upload target#3439
Conversation
There was a problem hiding this comment.
Pull request overview
Adds QRZCALL.EU as a real-time QSO upload target alongside the existing QRZ.com / Club Log / HRDLog integrations. A new station-profile field stores a per-user Personal Access Token (PAT), and the Logbook_model pushes newly logged QSOs (INSERT) and edited QSOs (REPLACE) to the QRZ-compatible api.qrzcall.eu/v1/pub/logbook_api.php endpoint, marking each QSO's upload status.
Changes:
- New migration
271_add_qrzcall_to_cloudlog.phpaddsqrzcallapikey/qrzcallrealtimetostation_profileandCOL_QRZCALL_QSO_UPLOAD_STATUS/_DATEto the QSO table. Logbook_modelgainspush_qso_to_qrzcall(),mark_qrzcall_qso_sent(),exists_qrzcall_api_key(), plus hooks inadd_qso()andedit();Stationsmodel persists the new profile fields; create/edit views expose the PAT and real-time toggle.- New Cypress test
6-qrzcall.cy.jsverifies the new station-profile UI fields.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| application/migrations/271_add_qrzcall_to_cloudlog.php | Adds QRZCALL.EU schema columns with idempotent guards. |
| application/config/migration.php | Bumps migration_version to 271. |
| application/models/Logbook_model.php | Implements real-time INSERT/REPLACE upload to QRZCALL.EU and result handling. |
| application/models/Stations.php | Saves new qrzcallapikey / qrzcallrealtime fields on create/edit. |
| application/views/station_profile/edit.php | Adds QRZCALL.EU PAT + real-time toggle card to edit form. |
| application/views/station_profile/create.php | Adds matching PAT + real-time toggle fields to create form. |
| cypress/e2e/6-qrzcall.cy.js | E2E test for the new station-profile UI controls. |
Comments suppressed due to low confidence (7)
application/models/Logbook_model.php:1656
- Unlike the existing HRDLog/QRZ.com/Clublog services, the QRZCALL.EU upload status column (
COL_QRZCALL_QSO_UPLOAD_STATUS) is never set to'M'here when a QSO is edited. The migration documentsM = modified/re-upload, but the edit flow only sets the status to'Y'viamark_qrzcall_qso_sentafter a successful REPLACE. If the real-time REPLACE fails (network/auth error, or the user has since rotated the PAT), the QSO will continue to display its old'Y'/upload date with no indication it has been modified and is now out of sync with QRZCALL.EU. Consider settingCOL_QRZCALL_QSO_UPLOAD_STATUS = 'M'in the$dataarray alongside the other services, mirroring lines 1614–1624.
if ($this->exists_hrdlog_credentials($data['station_id'])) {
$data['COL_HRDLOG_QSO_UPLOAD_STATUS'] = 'M';
}
if ($this->exists_qrz_api_key($data['station_id'])) {
$data['COL_QRZCOM_QSO_UPLOAD_STATUS'] = 'M';
}
if ($this->exists_clublog_credentials($data['station_id'])) {
$data['COL_CLUBLOG_QSO_UPLOAD_STATUS'] = 'M';
}
// Reset LoTW and eQSL sent status to 'N' if they were previously sent
// This ensures edited QSOs get re-uploaded to these services
if ($qso->COL_LOTW_QSL_SENT == 'Y' && $data['COL_LOTW_QSL_SENT'] == 'Y') {
$data['COL_LOTW_QSL_SENT'] = 'N';
$data['COL_LOTW_QSLSDATE'] = null;
}
if ($qso->COL_EQSL_QSL_SENT == 'Y' && $data['COL_EQSL_QSL_SENT'] == 'Y') {
$data['COL_EQSL_QSL_SENT'] = 'N';
$data['COL_EQSL_QSLSDATE'] = null;
}
$this->db->where('COL_PRIMARY_KEY', $this->input->post('id'));
$this->db->update($this->config->item('table_name'), $data);
// Real-time re-upload of the edited QSO to QRZCALL.EU. The QSO already
// exists there from the create-time upload, so it is sent with
// OPTION=REPLACE — QRZCALL.EU updates the existing record in place.
$qrzcall_station = $this->exists_qrzcall_api_key($data['station_id']);
if (isset($qrzcall_station->qrzcallapikey) && !empty($qrzcall_station->qrzcallapikey) && $qrzcall_station->qrzcallrealtime == 1) {
$CI = &get_instance();
$CI->load->library('AdifHelper');
$qso = $this->get_qso($this->input->post('id'), true)->result();
if (!empty($qso)) {
$adif = $CI->adifhelper->getAdifLine($qso[0]);
$upload = $this->push_qso_to_qrzcall($qrzcall_station->qrzcallapikey, $adif, true);
if ($upload['status'] == 'OK') {
$this->mark_qrzcall_qso_sent($this->input->post('id'));
}
}
}
application/models/Logbook_model.php:1141
stristr($content, 'duplicate')is too loose a check. The QRZ-compatible logbook endpoint typically returns structured fields likeRESULT=FAIL&REASON=...duplicate...; matching the bare substringduplicateanywhere in the response means an unrelated error message that happens to mention the word (e.g. in an error explanation) would be silently treated as a successful upload and the QSO would be markedY. The existing QRZ.com handling at line 828 matches the full reason string (STATUS=FAIL&REASON=Unable to add QSO to database: duplicate&EXTENDED=). Consider tightening this check similarly.
} elseif (stristr($content, 'duplicate')) {
// QRZCALL.EU rejected the QSO because an identical one is already in
// the logbook. Treat it as success so the QSO is marked uploaded and
// not retried on every run (mirrors Cloudlog's QRZ.com handling).
$result['status'] = 'OK';
$result['duplicate'] = true;
return $result;
application/models/Logbook_model.php:1138
- The comment claims duplicates are treated as success "so the QSO is marked uploaded and not retried on every run", but as stated in the PR description there is no bulk/cron upload route for QRZCALL.EU — synchronisation is real-time only and fires exactly once per save. The justification doesn't apply to QRZCALL.EU in its current form; consider clarifying the rationale (e.g. it just normalises an already-uploaded QSO to status
Y).
// QRZCALL.EU rejected the QSO because an identical one is already in
// the logbook. Treat it as success so the QSO is marked uploaded and
// not retried on every run (mirrors Cloudlog's QRZ.com handling).
application/models/Logbook_model.php:1157
curl_close($ch)is called immediately aftercurl_exec(), but thecurl_errno($ch)check below it executes against a closed handle. In PHP, once the handle is closed the errno is no longer meaningful, so this branch will never report a real curl error — only the surroundingif ($content)block can return a result. Whencurl_execreturnsfalse(network failure, TLS error, etc.) this function falls through and returnsnull, which then trips$upload['status']access in the callers (add_qso/edit) with an "undefined index" notice. Movecurl_close()to after thecurl_errnocheck (as the WebADIF/HRDLog/QRZ.com functions do) and ensure a final error result is returned when$contentis false.
$content = curl_exec($ch);
curl_close($ch);
if ($content) {
if (stristr($content, 'RESULT=OK') || stristr($content, 'RESULT=REPLACE')) {
$result['status'] = 'OK';
return $result;
} elseif (stristr($content, 'duplicate')) {
// QRZCALL.EU rejected the QSO because an identical one is already in
// the logbook. Treat it as success so the QSO is marked uploaded and
// not retried on every run (mirrors Cloudlog's QRZ.com handling).
$result['status'] = 'OK';
$result['duplicate'] = true;
return $result;
} elseif (stristr($content, 'RESULT=AUTH')) {
$result['status'] = 'error';
$result['message'] = $content;
return $result;
} else {
$result['status'] = 'error';
$result['message'] = $content;
return $result;
}
}
if (curl_errno($ch)) {
$result['status'] = 'error';
$result['message'] = 'Curl error: ' . curl_errno($ch);
return $result;
}
}
application/models/Logbook_model.php:1656
- When a user edits a QSO, this code unconditionally sends
OPTION=REPLACE. If the user only enabled the QRZCALL.EU PAT (or real-time upload) after the QSO was originally created, the QSO does not exist on QRZCALL.EU and a REPLACE will fail. The QSO will then never be uploaded via the edit path. Consider checking whether the QSO was previously uploaded (e.g.COL_QRZCALL_QSO_UPLOAD_STATUS == 'Y') and falling back to a plain INSERT otherwise — or simply always using INSERT, since the duplicate-handling branch already covers the re-upload case.
// Real-time re-upload of the edited QSO to QRZCALL.EU. The QSO already
// exists there from the create-time upload, so it is sent with
// OPTION=REPLACE — QRZCALL.EU updates the existing record in place.
$qrzcall_station = $this->exists_qrzcall_api_key($data['station_id']);
if (isset($qrzcall_station->qrzcallapikey) && !empty($qrzcall_station->qrzcallapikey) && $qrzcall_station->qrzcallrealtime == 1) {
$CI = &get_instance();
$CI->load->library('AdifHelper');
$qso = $this->get_qso($this->input->post('id'), true)->result();
if (!empty($qso)) {
$adif = $CI->adifhelper->getAdifLine($qso[0]);
$upload = $this->push_qso_to_qrzcall($qrzcall_station->qrzcallapikey, $adif, true);
if ($upload['status'] == 'OK') {
$this->mark_qrzcall_qso_sent($this->input->post('id'));
}
}
}
application/models/Logbook_model.php:2356
exists_qrzcall_api_key()returns the row even whenqrzcallapikeyis NULL/empty, so the function name is slightly misleading — it doesn't actually verify a key exists. The callers compensate with!empty($result->qrzcallapikey)checks, but consider either addingAND qrzcallapikey IS NOT NULL AND qrzcallapikey != ''to the SQL or renaming toget_qrzcall_credentialsto matchexists_hrdlog_credentials/exists_clublog_credentialssemantics (which similarly just fetch the row).
function exists_qrzcall_api_key($station_id)
{
$sql = 'select qrzcallapikey, qrzcallrealtime from station_profile
where station_id = ?';
$query = $this->db->query($sql, $station_id);
$result = $query->row();
if ($result) {
return $result;
} else {
return false;
}
}
application/views/station_profile/edit.php:1055
- The QRZCALL.EU PAT field uses
type="text", which renders the secret token in plain view and lets the browser autofill/store it like any text input. Other secret-bearing inputs on this form (e.g. existing QRZ/Clublog/HRDLog credential fields, depending on how they're rendered) are typically masked. Consider usingtype="password"(or at leastautocomplete="off") to avoid casual shoulder-surfing exposure of the Personal Access Token. Applies to both create.php (line 329) and edit.php (line 1055).
<input type="text" class="form-control" name="qrzcallapikey" id="qrzcallApiKey" aria-describedby="qrzcallApiKeyHelp" placeholder="pat_xxxxx" value="<?php if(set_value('qrzcallapikey') != "") { echo set_value('qrzcallapikey'); } else { echo $my_station_profile->qrzcallapikey; } ?>">
| $ci = & get_instance(); | ||
| $ua = 'Cloudlog/' . $ci->config->item('app_version'); |
| <h5 class="card-header">QRZCALL.EU <span class="badge text-bg-warning">Data / Extra</span></h5> <!-- This does not need Multilanguage Support --> | ||
| <div class="card-body"> | ||
| <div class="mb-3"> | ||
| <label for="qrzcallApiKey">QRZCALL.EU Personal Access Token</label> <!-- This does not need Multilanguage Support --> | ||
| <input type="text" class="form-control" name="qrzcallapikey" id="qrzcallApiKey" aria-describedby="qrzcallApiKeyHelp" placeholder="pat_xxxxx" value="<?php if(set_value('qrzcallapikey') != "") { echo set_value('qrzcallapikey'); } else { echo $my_station_profile->qrzcallapikey; } ?>"> | ||
| <small id="qrzcallApiKeyHelp" class="form-text text-muted">Generate at <a href="https://qrzcall.eu/" target="_blank">qrzcall.eu</a> → My Profile → Account → API Tokens (requires Data or Extra subscription)</small> | ||
| </div> | ||
| <div class="mb-3"> | ||
| <label for="qrzcallrealtime">Real-time QSO upload</label> <!-- This does not need Multilanguage Support --> | ||
| <select class="form-select" id="qrzcallrealtime" name="qrzcallrealtime"> | ||
| <option value="1" <?php if ($my_station_profile->qrzcallrealtime == 1) { echo " selected=\"selected\""; } ?>><?php echo lang("general_word_yes"); ?></option> | ||
| <option value="0" <?php if ($my_station_profile->qrzcallrealtime == 0) { echo " selected=\"selected\""; } ?>><?php echo lang("general_word_no"); ?></option> | ||
| </select> | ||
| </div> |
1833a35 to
8f4b08d
Compare
|
Thanks for the review. Fixed the valid points; checked each against the existing QRZ.com implementation. Fixed in
Checked but left as-is — already consistent with QRZ.com
Verified on the Docker dev environment: real-time create, edit (REPLACE) and duplicate handling all confirmed. |
Add QRZCALL.EU as a QSO upload target
This PR lets Cloudlog mirror QSOs to a user's QRZCALL.EU
logbook in real time, alongside the existing QRZ.com / Club Log / HRDLog
upload targets.
It builds on the QRZCALL.EU callbook-provider PR, but is functionally
independent — the callbook lookup reads a token from
config.php, whereasQSO upload uses a per-station-profile PAT. The two can be reviewed and merged
in either order.
How it works
QRZCALL.EU exposes a QRZ-compatible logbook endpoint
(
api.qrzcall.eu/v1/pub/logbook_api.php) that accepts the sameKEY=&ACTION=INSERT&ADIF=request format Cloudlog already uses for QRZ.com.Upload is real-time and happens automatically for station profiles that
have a QRZCALL.EU PAT with real-time upload enabled:
create_qso()) → the new QSO is sent immediately (INSERT)edit()) → the changed QSO is sent immediately withOPTION=REPLACE, so QRZCALL.EU updates the existing record in place ratherthan creating a duplicate
There is no bulk / cron upload route — synchronisation is real-time only.
Bulk import/export is handled on the QRZCALL.EU website itself, not from
external loggers.
Each upload records its status and timestamp on the QSO
(
COL_QRZCALL_QSO_UPLOAD_STATUS/_DATE).Changes
7 files — 2 new, 5 modified
application/migrations/271_add_qrzcall_to_cloudlog.phpqrzcallapikey/qrzcallrealtimetostation_profile; addsCOL_QRZCALL_QSO_UPLOAD_STATUS/COL_QRZCALL_QSO_UPLOAD_DATEto the QSO tablecypress/e2e/6-qrzcall.cy.jsapplication/models/Logbook_model.phppush_qso_to_qrzcall()(sendsOPTION=REPLACEfor edits; treats aduplicateresponse as success),mark_qrzcall_qso_sent(),exists_qrzcall_api_key(); real-time INSERT hook increate_qso(); real-time REPLACE hook inedit()application/config/migration.phpmigration_versionto271application/models/Stations.phpqrzcallapikey/qrzcallrealtimeincreate()andedit()application/views/station_profile/edit.phpapplication/views/station_profile/create.phpConfiguration
Station Profile → enter the QRZCALL.EU PAT and set real-time upload to Yes.
QSOs logged or edited under that station profile then sync automatically.
Testing
Verified on the Docker development environment (PHP 7.4, MySQL):
field_existsguards)YOPTION=REPLACE;QRZCALL.EU updates the existing record in place (verified
RESULT=REPLACE,same logbook ID, no duplicate row)
untouched (no retry loop — the hook fires once per save)
6-qrzcall.cy.jspasses; existing suite unaffectedTest account — a shared account is available for reviewers:
Enter the PAT in a station profile (Station Profile → QRZCALL.EU card), enable
real-time upload, then log or edit a QSO.
Screenshot
The QRZCALL.EU card on the station profile (PAT field + real-time toggle) is
attached to this PR.
No breaking changes
All changes are additive. Upload only happens for station profiles that have
a QRZCALL.EU PAT set with real-time upload enabled. Existing QRZ.com / Club Log
/ HRDLog upload behaviour is unchanged.