diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml index 2eb9a3ce..6da2c638 100644 --- a/.github/workflows/pull_request.yml +++ b/.github/workflows/pull_request.yml @@ -41,10 +41,14 @@ jobs: - name: Install dependencies run: | - cd tools + cd tools ls -lah python -m pip install --upgrade pip pip install -r requirements.txt + pip install -r certificate_automation/requirements.txt - name: Run pytest - run: pytest tools + run: | + cd tools + export PYTHONPATH="${PYTHONPATH}:$(pwd)" + pytest --ignore=_site --ignore=.venv --ignore=myenv diff --git a/.gitignore b/.gitignore index 3665bf60..673390b2 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,9 @@ tools/blog_automation/venv # Claude Code local settings (may contain personal preferences) .claude/settings.local.json + +# Certificate automation - proprietary templates and generated files +tools/certificate_automation/data/input/templates/ +tools/certificate_automation/data/input/*.csv +tools/certificate_automation/data/output/ +tools/samples/ diff --git a/_includes/footer.html b/_includes/footer.html index 946f6268..75122a73 100644 --- a/_includes/footer.html +++ b/_includes/footer.html @@ -75,6 +75,7 @@

Follow Us

+ diff --git a/_sass/custom/_verify.scss b/_sass/custom/_verify.scss new file mode 100644 index 00000000..280ff0c8 --- /dev/null +++ b/_sass/custom/_verify.scss @@ -0,0 +1,117 @@ +.page-verify { + .verification-container { + max-width: 800px; + margin: 50px auto; + padding: 30px; + text-align: center; + } + + .verification-box { + background: #f8f9fa; + border-radius: 10px; + padding: 40px; + margin: 30px 0; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + + .certificate-info { + background: white; + border-left: 4px solid $success; + padding: 20px; + margin: 20px 0; + text-align: left; + + &.invalid { + border-left: 4px solid $danger; + } + + h3 { + margin-top: 0; + color: $success; + } + + &.invalid h3 { + color: $danger; + } + } + + .info-row { + margin: 10px 0; + display: flex; + justify-content: space-between; + } + + .info-label { + font-weight: bold; + color: #555; + } + + .info-value { + color: #333; + } + + .search-box { + display: flex; + gap: 10px; + margin: 20px 0; + justify-content: center; + + input { + padding: 12px 20px; + font-size: 16px; + border: 2px solid #ddd; + border-radius: 5px; + width: 300px; + } + + button { + padding: 12px 30px; + font-size: 16px; + background: $primary; + color: white; + border: none; + border-radius: 5px; + cursor: pointer; + transition: background 0.3s; + + &:hover { + background: darken($primary, 10%); + } + } + } + + .loading { + display: none; + color: $primary; + margin: 20px 0; + } + + .error-message { + color: $danger; + margin: 20px 0; + } + + .success-icon { + font-size: 60px; + color: $success; + margin-bottom: 20px; + } + + .error-icon { + font-size: 60px; + color: $danger; + margin-bottom: 20px; + } + + .verification-instructions { + h3 { + margin-bottom: 20px; + } + + ol { + text-align: left; + max-width: 500px; + margin: 0 auto; + } + } +} diff --git a/_sass/custom/custom.scss b/_sass/custom/custom.scss index 7f7773a2..856916d0 100644 --- a/_sass/custom/custom.scss +++ b/_sass/custom/custom.scss @@ -19,6 +19,7 @@ @import "breadcrumbs"; @import "partners"; @import "timeline"; +@import "verify"; .network svg:hover { fill: $primary-50 !important; diff --git a/assets/js/certificates_registry.json b/assets/js/certificates_registry.json new file mode 100644 index 00000000..26f90e8e --- /dev/null +++ b/assets/js/certificates_registry.json @@ -0,0 +1,515 @@ +{ + "certificates": [ + { + "id": "605C57FEA859", + "name": "Irina Kamalova", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=605C57FEA859" + }, + { + "id": "EBE7323362EC", + "name": "Busra Ecem Sakar", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=EBE7323362EC" + }, + { + "id": "5BEF3EB09FB0", + "name": "Stephanie Senoner", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=5BEF3EB09FB0" + }, + { + "id": "E43339C4EBB5", + "name": "Samuela Smolorz", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=E43339C4EBB5" + }, + { + "id": "84D14102EA90", + "name": "Sonali Goel", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=84D14102EA90" + }, + { + "id": "2F84A9719032", + "name": "Airat Yusuff", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=2F84A9719032" + }, + { + "id": "16F9E90EFC40", + "name": "Madhura Chaganty", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=16F9E90EFC40" + }, + { + "id": "9B79984A629A", + "name": "Nonna Shakhova", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=9B79984A629A" + }, + { + "id": "B08A5C109066", + "name": "Shabana", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B08A5C109066" + }, + { + "id": "2C0B2EAE2063", + "name": "Damola Taiwo", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=2C0B2EAE2063" + }, + { + "id": "8F6DD737D4D5", + "name": "Julia Babahina", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=8F6DD737D4D5" + }, + { + "id": "983E6FAE5DE3", + "name": "Liliia Rafikova", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=983E6FAE5DE3" + }, + { + "id": "BC056B86DFE0", + "name": "Silke Nodwell", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=BC056B86DFE0" + }, + { + "id": "B206037366F0", + "name": "Eleonora Belova", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B206037366F0" + }, + { + "id": "B93910B0FACB", + "name": "Idayat Sanni", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B93910B0FACB" + }, + { + "id": "D8A23BC73EF8", + "name": "Rajashree Munoli", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=D8A23BC73EF8" + }, + { + "id": "46473E6BFB16", + "name": "Ying Liu", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=46473E6BFB16" + }, + { + "id": "EAAF2197DEA2", + "name": "Rajani Rao", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=EAAF2197DEA2" + }, + { + "id": "57DDA891555D", + "name": "Prabha Venkatesh", + "type": "leader", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=57DDA891555D" + }, + { + "id": "83EA865465D6", + "name": "Turdugul Okonbaeva", + "type": "evangelist", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=83EA865465D6" + }, + { + "id": "ADA887801B66", + "name": "Arzu Guney", + "type": "evangelist", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=ADA887801B66" + }, + { + "id": "C36F2B76D135", + "name": "Joana Brito", + "type": "evangelist", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=C36F2B76D135" + }, + { + "id": "85C837878191", + "name": "Rashidat Adekoya", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=85C837878191" + }, + { + "id": "04851D01E7A7", + "name": "Anna Cotogno", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=04851D01E7A7" + }, + { + "id": "40F6A957BD73", + "name": "Nevena Verbič", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=40F6A957BD73" + }, + { + "id": "E999B67B2136", + "name": "Luiza Gretzk", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=E999B67B2136" + }, + { + "id": "8175C84EA29A", + "name": "Zaynah Ahmed", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=8175C84EA29A" + }, + { + "id": "155781E3D66D", + "name": "Valeria", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=155781E3D66D" + }, + { + "id": "6472DF00CB8F", + "name": "Olivia Thetgyi", + "type": "backend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=6472DF00CB8F" + }, + { + "id": "1B81E1BE9498", + "name": "Shabana", + "type": "newsletter", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=1B81E1BE9498" + }, + { + "id": "93C95ADBCC42", + "name": "Sowmiya Ravikumar", + "type": "newsletter", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=93C95ADBCC42" + }, + { + "id": "7FA10A2CFEFF", + "name": "Silke Nodwell", + "type": "newsletter", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=7FA10A2CFEFF" + }, + { + "id": "B0B0DF8A88FF", + "name": "Sahana Venkatesh", + "type": "newsletter", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B0B0DF8A88FF" + }, + { + "id": "28B0B8FA0975", + "name": "Pranita Panse", + "type": "qa", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=28B0B8FA0975" + }, + { + "id": "39680B16C80D", + "name": "Gabriel Oliveira", + "type": "qa", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=39680B16C80D" + }, + { + "id": "D227D7C84125", + "name": "Purnima Tyagi", + "type": "qa", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=D227D7C84125" + }, + { + "id": "B1CBE53C73BE", + "name": "Mioara Cenusa", + "type": "qa", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B1CBE53C73BE" + }, + { + "id": "9096E012308F", + "name": "Patrycja Gontarek", + "type": "qa", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=9096E012308F" + }, + { + "id": "5A6F6ED0C630", + "name": "Idayat Sanni", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=5A6F6ED0C630" + }, + { + "id": "A9633C3BFD14", + "name": "Adniloet Sosa Ricardo", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=A9633C3BFD14" + }, + { + "id": "1E0FAF0C3F85", + "name": "Sandra Barbosa", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=1E0FAF0C3F85" + }, + { + "id": "C1CC90009B9C", + "name": "Anusha Devi", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=C1CC90009B9C" + }, + { + "id": "9F2A7F60007E", + "name": "Mitali Shah", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=9F2A7F60007E" + }, + { + "id": "8DF285CD9774", + "name": "Joana Brito", + "type": "frontend", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=8DF285CD9774" + }, + { + "id": "5D1BE8D49C20", + "name": "Mehul Varsha Singh", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=5D1BE8D49C20" + }, + { + "id": "29D802E81437", + "name": "Amrit Atwal", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=29D802E81437" + }, + { + "id": "B688DBDE81BE", + "name": "Silke Nodwell", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=B688DBDE81BE" + }, + { + "id": "524F632B24E3", + "name": "Turdugul Okonbaeva", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=524F632B24E3" + }, + { + "id": "5117E8E8FA21", + "name": "Marj Martinez", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=5117E8E8FA21" + }, + { + "id": "A37FC6C141DD", + "name": "Damola Taiwo", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=A37FC6C141DD" + }, + { + "id": "DD035A8EBFD0", + "name": "Ying Liu", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=DD035A8EBFD0" + }, + { + "id": "493B19357B9B", + "name": "Helen T", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=493B19357B9B" + }, + { + "id": "A7DDF0243D59", + "name": "Liliiia Rafikova", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=A7DDF0243D59" + }, + { + "id": "66D2264F27B3", + "name": "Maryam Yusuf", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=66D2264F27B3" + }, + { + "id": "2C75B1448408", + "name": "Sarah Usher", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=2C75B1448408" + }, + { + "id": "540C32C3CAE8", + "name": "Sowmiya Ravikumar", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=540C32C3CAE8" + }, + { + "id": "FF6EEE313731", + "name": "Rohini", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=FF6EEE313731" + }, + { + "id": "0EC4A06618F0", + "name": "Afsha", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=0EC4A06618F0" + }, + { + "id": "F7BA69975B3C", + "name": "Afolake Odubote", + "type": "api-days", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=F7BA69975B3C" + }, + { + "id": "6525D8A1B0A3", + "name": "Madhura Chaganty", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=6525D8A1B0A3" + }, + { + "id": "0D6D6F51997F", + "name": "Ramona Gawarwala", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=0D6D6F51997F" + }, + { + "id": "CDF97DF8CC4B", + "name": "Damola Taiwo", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=CDF97DF8CC4B" + }, + { + "id": "C8595266F1B2", + "name": "Helen T", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=C8595266F1B2" + }, + { + "id": "5553D8A5BDB8", + "name": "Linda Okorie-Kalu", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=5553D8A5BDB8" + }, + { + "id": "2BAEDBDB34FC", + "name": "Busra Ecem Sakar", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=2BAEDBDB34FC" + }, + { + "id": "065B4F768306", + "name": "Darshna Agrawal", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=065B4F768306" + }, + { + "id": "7623B8C6DF3F", + "name": "Prabha Venkatesh", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=7623B8C6DF3F" + }, + { + "id": "AABA25C7C05A", + "name": "Emma Jourzac", + "type": "half-stack", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=AABA25C7C05A" + }, + { + "id": "770F5D11A10F", + "name": "Sonali Goel", + "type": "mentorship", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=770F5D11A10F" + }, + { + "id": "0395F70E1B62", + "name": "Nevena Verbič", + "type": "mentorship", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=0395F70E1B62" + }, + { + "id": "2530831A1395", + "name": "Isabell", + "type": "leetcode", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=2530831A1395" + }, + { + "id": "105EE35026BE", + "name": "Youning", + "type": "leetcode", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=105EE35026BE" + }, + { + "id": "9EA8B33C31FD", + "name": "Zaynah Ahmed", + "type": "leetcode", + "issue_date": "2026-01-05", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=9EA8B33C31FD" + } + ] +} \ No newline at end of file diff --git a/assets/js/verify.js b/assets/js/verify.js new file mode 100644 index 00000000..307eac6f --- /dev/null +++ b/assets/js/verify.js @@ -0,0 +1,180 @@ +let controllerVerify = (function (jQuery) { + const certIdInput = jQuery('#certId'); + const loading = jQuery('#loading'); + const result = jQuery('#result'); + const verifyBtn = jQuery('#verify-btn'); + + /** + * Parse URL parameters + * @param {string} name - Parameter name to retrieve + * @returns {string} - Parameter value or empty string + */ + let getUrlParameter = function (name) { + name = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); + const regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); + const results = regex.exec(location.search); + return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); + }; + + /** + * Display loading state + */ + let showLoading = function () { + loading.show(); + result.html(''); + }; + + /** + * Hide loading state + */ + let hideLoading = function () { + loading.hide(); + }; + + /** + * Display valid certificate information + * @param {object} certificate - Certificate data object + */ + let displayValidCertificate = function (certificate) { + const certType = certificate.type.charAt(0).toUpperCase() + certificate.type.slice(1); + + result.html(` +
+
+

Valid Certificate

+
+ Certificate ID: + ${certificate.id} +
+
+ Recipient Name: + ${certificate.name} +
+
+ Certificate Type: + ${certType} +
+
+ Issue Date: + ${certificate.issue_date} +
+
+ `); + }; + + /** + * Display invalid certificate message + * @param {string} certId - Certificate ID that was searched + */ + let displayInvalidCertificate = function (certId) { + result.html(` +
+
+

Invalid Certificate

+

No certificate found with ID: ${certId}

+

This certificate may be fraudulent or the ID was entered incorrectly.

+
+ `); + }; + + /** + * Display error message + * @param {string} errorMessage - Error message to display + */ + let displayError = function (errorMessage) { + result.html(` +

Error verifying certificate: ${errorMessage}

+

Please try again later or contact support.

+ `); + }; + + /** + * Verify certificate by ID + */ + let verifyCertificate = async function () { + const certId = certIdInput.val().trim().toUpperCase(); + + if (!certId) { + result.html('

Please enter a certificate ID

'); + return; + } + + showLoading(); + + try { + const response = await fetch('/assets/js/certificates_registry.json'); + + if (!response.ok) { + throw new Error('Unable to load certificate registry'); + } + + const registry = await response.json(); + const certificate = registry.certificates.find(cert => cert.id === certId); + + hideLoading(); + + if (certificate) { + displayValidCertificate(certificate); + } else { + displayInvalidCertificate(certId); + } + } catch (error) { + hideLoading(); + displayError(error.message); + } + }; + + /** + * Auto-verify if cert ID is in URL + */ + let autoVerifyFromUrl = function () { + const certId = getUrlParameter('cert'); + if (certId) { + certIdInput.val(certId); + verifyCertificate(); + } + }; + + /** + * Handle Enter key press to trigger verification + * @param {object} e - Keyboard event + */ + let handleEnterKeyPress = function (e) { + const isEnterKey = e.key === 'Enter'; + if (isEnterKey) { + e.preventDefault(); + verifyCertificate(); + } + }; + + /** + * Initialize event handlers + */ + let initEvents = function () { + verifyBtn.on('click', function (e) { + e.preventDefault(); + verifyCertificate(); + }); + + certIdInput.on('keydown', handleEnterKeyPress); + }; + + /** + * Initialize the controller + */ + let init = function () { + const isOnVerifyPage = certIdInput.length > 0; + if (!isOnVerifyPage) { + return; + } + + initEvents(); + autoVerifyFromUrl(); + }; + + return { + init: init + }; +}(jQuery)); + +controllerVerify.init(); diff --git a/test/assets/verify.test.js b/test/assets/verify.test.js new file mode 100644 index 00000000..3077212a --- /dev/null +++ b/test/assets/verify.test.js @@ -0,0 +1,154 @@ +const {JSDOM} = require('jsdom'); + +describe('Certificate Verification Page', () => { + let $; + let document; + let window; + + beforeEach(() => { + const dom = new JSDOM(` + + + +
+ + +
+
+ + + `, { + url: 'http://localhost' + }); + + global.document = dom.window.document; + global.window = dom.window; + global.location = dom.window.location; + global.jQuery = require('jquery'); + global.$ = global.jQuery; + + $ = global.jQuery; + document = global.document; + window = global.window; + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + delete global.document; + delete global.window; + delete global.location; + delete global.jQuery; + delete global.$; + delete global.fetch; + }); + + test('page elements exist', () => { + expect(document.getElementById('certId')).toBeTruthy(); + expect(document.getElementById('verify-btn')).toBeTruthy(); + expect(document.getElementById('loading')).toBeTruthy(); + expect(document.getElementById('result')).toBeTruthy(); + }); + + test('certificate ID input accepts text', () => { + const certIdInput = $('#certId'); + certIdInput.val('ABC123'); + expect(certIdInput.val()).toBe('ABC123'); + }); + + test('verify button exists and is clickable', () => { + const verifyBtn = $('#verify-btn'); + expect(verifyBtn.length).toBe(1); + expect(verifyBtn.text()).toBe('Verify'); + + let clicked = false; + verifyBtn.on('click', () => { + clicked = true; + }); + + verifyBtn.trigger('click'); + expect(clicked).toBe(true); + }); + + test('loading indicator is initially hidden', () => { + const loading = $('#loading'); + expect(loading.css('display')).toBe('none'); + }); + + test('result div is initially empty', () => { + const result = $('#result'); + expect(result.html()).toBe(''); + }); + + test('can simulate Enter key press on input', () => { + const certIdInput = $('#certId'); + let keyPressed = false; + + certIdInput.on('keydown', (e) => { + if (e.key === 'Enter') { + keyPressed = true; + } + }); + + // Simulate Enter key + const event = $.Event('keydown'); + event.key = 'Enter'; + certIdInput.trigger(event); + + expect(keyPressed).toBe(true); + }); + + test('fetch API is mocked correctly', async () => { + const mockData = {certificates: []}; + global.fetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockData + }); + + const response = await fetch('/assets/js/certificates_registry.json'); + const data = await response.json(); + + expect(data).toEqual(mockData); + expect(global.fetch).toHaveBeenCalledWith('/assets/js/certificates_registry.json'); + }); + + test('jQuery is available globally', () => { + expect(global.jQuery).toBeDefined(); + expect(global.$).toBeDefined(); + expect(global.jQuery).toBe(global.$); + }); + + test('can manipulate DOM with jQuery', () => { + const result = $('#result'); + result.html('

Test

'); + + expect(result.find('p').hasClass('success')).toBe(true); + expect(result.find('p').text()).toBe('Test'); + }); + + test('can toggle element visibility', () => { + const loading = $('#loading'); + + expect(loading.is(':visible')).toBe(false); + + loading.show(); + expect(loading.css('display')).not.toBe('none'); + + loading.hide(); + expect(loading.css('display')).toBe('none'); + }); + + test('URL parameters can be accessed', () => { + const domWithParam = new JSDOM('', { + url: 'http://localhost?cert=ABC123' + }); + + const urlParams = new domWithParam.window.URLSearchParams(domWithParam.window.location.search); + expect(urlParams.get('cert')).toBe('ABC123'); + }); +}); diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/certificate_automation/README.md b/tools/certificate_automation/README.md index e1b418c1..5d6f0328 100644 --- a/tools/certificate_automation/README.md +++ b/tools/certificate_automation/README.md @@ -3,7 +3,8 @@ Automated certificate generation from PowerPoint templates and converting them to PDF format using JSON configuration > **⚠️ Warning: PDF conversion currently only works on Windows** -> The PDF export functionality uses Microsoft PowerPoint COM automation via `comtypes`, which is only available on Windows. On macOS and Linux, the script will generate PPTX files, but PDF conversion will not work. +> The PDF export functionality uses Microsoft PowerPoint COM automation via `comtypes`, which is only available on +> Windows. On macOS and Linux, the script will generate PPTX files, but PDF conversion will not work. ## Prerequisites @@ -13,17 +14,17 @@ Automated certificate generation from PowerPoint templates and converting them t ## Installation Install required Python packages: + ```bash pip install -r requirements.txt ``` - #### Dependencies - `python-pptx>=0.6.21`: PowerPoint file manipulation - `comtypes>=1.1.14`: COM automation for PowerPoint - - +- `qrcode>=7.4.2`: QR code generation for certificate verification +- `pillow>=10.0.0`: Image processing for QR codes ## Project Structure @@ -67,27 +68,45 @@ Edit `src/config.json` to customize certificate generation settings. "pdf_dir": "../data/output/pdfs/mentee/", "ppt_dir": "../data/output/ppts/mentee/", "placeholder_text": "Sample Sample", - "font_name": "Georgia", - "font_size": 59.5 + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 } ] } ``` -- **type**: Certificate type identifier (e.g., "mentee", "mentor") +#### Required Parameters + +- **type**: Certificate type identifier (e.g., "mentee", "mentor", "volunteer", "leader") - **template**: Path to the PPTX template file - **names_file**: Path to text file containing names (one per line) - **pdf_dir**: Output directory for PDF certificates - **ppt_dir**: Output directory for PPTX certificates - **placeholder_text**: Text in template to be replaced with names -- **font_name**: Font to use for names -- **font_size**: Font size in points + +#### Optional Parameters (QR Code Position) + +- **qr_left_cm**: Distance from left edge in centimeters (e.g., 47.8) +- **qr_top_cm**: Distance from top edge in centimeters (e.g., 28.91) +- **qr_width_cm**: QR code width in centimeters (default: 3.0) +- **qr_height_cm**: QR code height in centimeters (default: 3.0) + +**Note**: If QR position parameters are not specified, the QR code will be placed in the top-right corner by default. + +#### Text Formatting + +All text formatting (font name, font size, color, bold, italic, underline) is **automatically preserved** from the +placeholder text in your PowerPoint template. Simply style the placeholder text ("Sample Sample") in your template +exactly how you want the names to appear, and the script will apply the same formatting to each person's name. ### Names Files Create text files in `data/input/names/` with one name per line: **Example** (`data/input/names/mentees.txt`): + ``` John Smith Jane Doe @@ -110,6 +129,7 @@ python generate_certificates.py ``` The script will automatically: + 1. Check for duplicate names in the input files 2. Generate PPTX certificates for each person 3. Verify all PPTX certificates were created @@ -120,9 +140,93 @@ The script will automatically: ## Output Format Generated certificate files are named using the person's name directly: + - PPTX: `data/output/ppts/mentee/John Smith.pptx` - PDF: `data/output/pdfs/mentee/John Smith.pdf` +## QR Code Verification + +Each generated certificate includes a QR code for verification purposes. The system automatically: + +1. **Generates Unique Certificate IDs**: Each certificate gets a unique ID based on the recipient's name, certificate + type, and issue date +2. **Embeds QR Codes**: QR codes are automatically added to the bottom-right corner of each certificate +3. **Maintains Certificate Registry**: All issued certificates are recorded in `data/output/certificate_registry.json` +4. **Provides Verification Page**: Recipients can verify certificates at `https://www.womencodingcommunity.com/verify` + +### How Verification Works + +1. **For Recipients**: Scan the QR code on your certificate or visit the verification page and enter your certificate ID +2. **For Verifiers**: The verification page checks the certificate against the official registry and displays: + - Certificate ID + - Recipient name + - Certificate type + - Issue date + - Validation status + +### Certificate Registry + +The certificate registry (`data/output/certificate_registry.json`) contains all issued certificates: + +```json +{ + "certificates": [ + { + "id": "ABC123DEF456", + "name": "John Smith", + "type": "mentee", + "issue_date": "2026-01-04", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + } + ] +} +``` + +**Important**: The certificate registry file must be committed to the repository and deployed to GitHub Pages for the +verification system to work. + +### Publishing Certificates for Web Verification + +After generating certificates, you need to publish the certificate registry to make it available on the website: + +1. **Generated Registry Location**: `tools/certificate_automation/data/output/certificate_registry.json` +2. **Published Registry Location**: `assets/js/certificates_registry.json` + +#### Option 1: Manual Copy (First Time) + +If this is your first batch of certificates: + +```bash +cp tools/certificate_automation/data/output/certificate_registry.json assets/js/certificates_registry.json +git add assets/js/certificates_registry.json +git commit -m "Add certificate registry for verification" +git push +``` + +#### Option 2: Append New Certificates (Recommended) + +If you already have certificates published and want to add new ones: + +```bash +python3 tools/certificate_automation/scripts/publish_registry.py +``` + +The script will: +- Read the existing `assets/js/certificates_registry.json` +- Read the newly generated `tools/certificate_automation/data/output/certificate_registry.json` +- Merge certificates, avoiding duplicates (by certificate ID) +- Save the merged result to `assets/js/certificates_registry.json` + +Then commit and push: + +```bash +git add assets/js/certificates_registry.json +git commit -m "Add new certificates to registry" +git push +``` + +**Note**: The `tools/` directory is for generation only and can be deleted/recreated. The `assets/js/certificates_registry.json` file is served by the website. + ## Sample Logs ``` @@ -145,4 +249,6 @@ Generating MENTEE mentee certificates at ../data/output/pdfs/mentee/ ... Type: mentee Total: 68 PPTX Generated: 68 PDF Generated: 68 + +Certificate registry saved with 68 total certificates ``` diff --git a/tools/certificate_automation/__init__.py b/tools/certificate_automation/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tools/certificate_automation/data/input/names/backend.txt b/tools/certificate_automation/data/input/names/backend.txt new file mode 100644 index 00000000..6099e2b7 --- /dev/null +++ b/tools/certificate_automation/data/input/names/backend.txt @@ -0,0 +1,2 @@ +FirstName LastName +Jane Doe \ No newline at end of file diff --git a/tools/certificate_automation/data/input/names/leaders.txt b/tools/certificate_automation/data/input/names/leaders.txt new file mode 100644 index 00000000..6099e2b7 --- /dev/null +++ b/tools/certificate_automation/data/input/names/leaders.txt @@ -0,0 +1,2 @@ +FirstName LastName +Jane Doe \ No newline at end of file diff --git a/tools/certificate_automation/data/input/names/mentees.txt b/tools/certificate_automation/data/input/names/mentees.txt index 7d0cf3b9..d5949387 100644 --- a/tools/certificate_automation/data/input/names/mentees.txt +++ b/tools/certificate_automation/data/input/names/mentees.txt @@ -1,3 +1,2 @@ -TestFN TestLN FirstName LastName TestFN TestLN diff --git a/tools/certificate_automation/data/input/names/volunteers.txt b/tools/certificate_automation/data/input/names/volunteers.txt new file mode 100644 index 00000000..6099e2b7 --- /dev/null +++ b/tools/certificate_automation/data/input/names/volunteers.txt @@ -0,0 +1,2 @@ +FirstName LastName +Jane Doe \ No newline at end of file diff --git a/tools/certificate_automation/pytest.ini b/tools/certificate_automation/pytest.ini new file mode 100644 index 00000000..04881bec --- /dev/null +++ b/tools/certificate_automation/pytest.ini @@ -0,0 +1,23 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + +# Coverage options +[coverage:run] +source = src +omit = + */tests/* + */__pycache__/* + */venv/* + +[coverage:report] +precision = 2 +show_missing = True +skip_covered = False diff --git a/tools/certificate_automation/requirements-dev.txt b/tools/certificate_automation/requirements-dev.txt new file mode 100644 index 00000000..bc87d179 --- /dev/null +++ b/tools/certificate_automation/requirements-dev.txt @@ -0,0 +1,12 @@ +# Development dependencies including test requirements +-r requirements.txt + +# Testing +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.11.1 +coverage>=7.3.0 + +# Code quality +flake8>=6.1.0 +black>=23.7.0 diff --git a/tools/certificate_automation/requirements.txt b/tools/certificate_automation/requirements.txt index 55e5f7da..2e987588 100644 --- a/tools/certificate_automation/requirements.txt +++ b/tools/certificate_automation/requirements.txt @@ -1,2 +1,4 @@ python-pptx>=0.6.21 comtypes>=1.1.14 +qrcode>=7.4.2 +pillow>=10.0.0 diff --git a/tools/certificate_automation/run_tests.py b/tools/certificate_automation/run_tests.py new file mode 100644 index 00000000..620a55e4 --- /dev/null +++ b/tools/certificate_automation/run_tests.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +Test runner for WCC Certificate Automation + +This script runs all unit and integration tests for the certificate automation system. + +Usage: + python run_tests.py # Run all tests + python run_tests.py -v # Run with verbose output + python run_tests.py --coverage # Run with coverage report +""" + +import sys +import os +import unittest +import argparse + + +def run_tests(verbosity=1, with_coverage=False): + """Run all tests in the tests directory.""" + + # Add src to path + src_path = os.path.join(os.path.dirname(__file__), 'src') + sys.path.insert(0, src_path) + + # Discover and run tests + loader = unittest.TestLoader() + start_dir = os.path.join(os.path.dirname(__file__), 'tests') + suite = loader.discover(start_dir, pattern='test_*.py') + + if with_coverage: + try: + import coverage + cov = coverage.Coverage() + cov.start() + + runner = unittest.TextTestRunner(verbosity=verbosity) + result = runner.run(suite) + + cov.stop() + cov.save() + + print("\n" + "="*70) + print("Coverage Report:") + print("="*70) + cov.report() + + # Generate HTML coverage report + html_dir = os.path.join(os.path.dirname(__file__), 'htmlcov') + cov.html_report(directory=html_dir) + print(f"\nHTML coverage report generated in: {html_dir}") + + return 0 if result.wasSuccessful() else 1 + + except ImportError: + print("Coverage package not installed. Install with: pip install coverage") + print("Running tests without coverage...\n") + + runner = unittest.TextTestRunner(verbosity=verbosity) + result = runner.run(suite) + + return 0 if result.wasSuccessful() else 1 + + +def main(): + """Main entry point.""" + parser = argparse.ArgumentParser(description='Run WCC Certificate Automation tests') + parser.add_argument('-v', '--verbose', action='store_true', + help='Verbose output') + parser.add_argument('-c', '--coverage', action='store_true', + help='Run with coverage report') + + args = parser.parse_args() + + verbosity = 2 if args.verbose else 1 + + print("Running WCC Certificate Automation Tests") + print("="*70) + + sys.exit(run_tests(verbosity=verbosity, with_coverage=args.coverage)) + + +if __name__ == '__main__': + main() diff --git a/tools/certificate_automation/scripts/publish_registry.py b/tools/certificate_automation/scripts/publish_registry.py new file mode 100755 index 00000000..e3010bfd --- /dev/null +++ b/tools/certificate_automation/scripts/publish_registry.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +Publish Certificate Registry + +Merges newly generated certificates from tools output directory +with existing published registry in _data/, avoiding duplicates. + +Usage: + python3 tools/certificate_automation/scripts/publish_registry.py +""" + +import json +import os +import sys +from pathlib import Path + + +def load_registry(file_path): + """Load certificate registry from file.""" + if not os.path.exists(file_path): + print(f"Registry file not found: {file_path}") + return {"certificates": []} + + try: + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except json.JSONDecodeError as e: + print(f"Error reading {file_path}: {e}") + return {"certificates": []} + + +def save_registry(registry, file_path): + """Save certificate registry to file.""" + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + print(f"Registry saved to: {file_path}") + + +def merge_registries(existing_registry, new_registry): + """Merge two registries, avoiding duplicates by certificate ID.""" + existing_certs = {cert['id']: cert for cert in existing_registry.get('certificates', [])} + added = 0 + skipped = 0 + + for cert in new_registry.get('certificates', []): + cert_id = cert['id'] + if cert_id in existing_certs: + skipped += 1 + print(f" Skipped duplicate: {cert_id} ({cert['name']})") + else: + existing_certs[cert_id] = cert + added += 1 + print(f" Added: {cert_id} ({cert['name']}, {cert['type']})") + + merged_certificates = sorted(existing_certs.values(), key=lambda x: x['name']) + + return {"certificates": merged_certificates}, added, skipped + + +def main(): + """Main function to publish certificate registry.""" + script_dir = Path(__file__).parent + project_root = script_dir.parent.parent.parent + + source_registry = project_root / "tools" / "certificate_automation" / "data" / "output" / "certificate_registry.json" + target_registry = project_root / "assets" / "js" / "certificates_registry.json" + + print("=" * 60) + print("Certificate Registry Publisher") + print("=" * 60) + print(f"\nSource: {source_registry}") + print(f"Target: {target_registry}\n") + + print("Loading registries...") + existing = load_registry(target_registry) + new = load_registry(source_registry) + + if not new.get('certificates'): + print("\n❌ Error: No certificates found in source registry.") + print(f" Please run certificate generation first.") + return 1 + + existing_count = len(existing.get('certificates', [])) + new_count = len(new.get('certificates', [])) + + print(f" Existing certificates: {existing_count}") + print(f" New certificates: {new_count}\n") + + print("Merging certificates...") + merged, added, skipped = merge_registries(existing, new) + + print(f"\n{'=' * 60}") + print("Summary:") + print(f" ✓ Added: {added}") + print(f" - Skipped (duplicates): {skipped}") + print(f" Total certificates: {len(merged['certificates'])}") + print(f"{'=' * 60}\n") + + if added > 0: + save_registry(merged, target_registry) + print("\n✅ Registry published successfully!") + print(f"\nNext steps:") + print(f" 1. Review changes: git diff assets/js/certificates_registry.json") + print(f" 2. Commit changes: git add assets/js/certificates_registry.json") + print(f" 3. Push to deploy: git push") + else: + print("\n✅ No new certificates to publish (all were duplicates).") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/certificate_automation/src/config.json b/tools/certificate_automation/src/config.json index 4f435c38..5ac9588a 100644 --- a/tools/certificate_automation/src/config.json +++ b/tools/certificate_automation/src/config.json @@ -6,9 +6,7 @@ "names_file": "../data/input/names/mentees.txt", "pdf_dir": "../data/output/pdfs/mentee/", "ppt_dir": "../data/output/ppts/mentee/", - "placeholder_text": "Sample Sample", - "font_name": "Georgia", - "font_size": 59.5 + "placeholder_text": "Sample Sample" }, { "type": "mentor", @@ -17,8 +15,94 @@ "pdf_dir": "../data/output/pdfs/mentor/", "ppt_dir": "../data/output/ppts/mentor/", "placeholder_text": "Sample Sample", - "font_name": "Georgia", - "font_size": 59.5 + "qr_left_cm": 52, + "qr_top_cm": 36, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "volunteer", + "template": "../data/input/templates/volunteer.pptx", + "names_file": "../data/input/names/volunteers.txt", + "pdf_dir": "../data/output/pdfs/volunteer/", + "ppt_dir": "../data/output/ppts/volunteer/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "leader", + "template": "../data/input/templates/leader.pptx", + "names_file": "../data/input/names/leaders.txt", + "pdf_dir": "../data/output/pdfs/leader/", + "ppt_dir": "../data/output/ppts/leader/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "evangelist", + "template": "../data/input/templates/evangelist.pptx", + "names_file": "../data/input/names/evangelist.txt", + "pdf_dir": "../data/output/pdfs/evangelist/", + "ppt_dir": "../data/output/ppts/evangelist/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "backend", + "template": "../data/input/templates/volunteer.pptx", + "names_file": "../data/input/names/backend.txt", + "pdf_dir": "../data/output/pdfs/backend/", + "ppt_dir": "../data/output/ppts/backend/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "newsletter", + "template": "../data/input/templates/volunteer.pptx", + "names_file": "../data/input/names/newsletter.txt", + "pdf_dir": "../data/output/pdfs/newsletter/", + "ppt_dir": "../data/output/ppts/newsletter/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "qa", + "template": "../data/input/templates/volunteer.pptx", + "names_file": "../data/input/names/qas.txt", + "pdf_dir": "../data/output/pdfs/qa/", + "ppt_dir": "../data/output/ppts/qa/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 + }, + { + "type": "frontend", + "template": "../data/input/templates/volunteer.pptx", + "names_file": "../data/input/names/frontends.txt", + "pdf_dir": "../data/output/pdfs/frontend/", + "ppt_dir": "../data/output/ppts/frontend/", + "placeholder_text": "Sample Sample", + "qr_left_cm": 47.8, + "qr_top_cm": 28.91, + "qr_width_cm": 3.0, + "qr_height_cm": 3.0 } ] } diff --git a/tools/certificate_automation/src/generate_certificates.py b/tools/certificate_automation/src/generate_certificates.py index b0687cd2..2cedff29 100644 --- a/tools/certificate_automation/src/generate_certificates.py +++ b/tools/certificate_automation/src/generate_certificates.py @@ -1,12 +1,17 @@ from collections import Counter from pathlib import Path import platform +import hashlib +from datetime import datetime from pptx import Presentation import os import json import sys -from pptx.util import Pt +from pptx.util import Pt, Inches, Cm +from pptx.dml.color import RGBColor +import qrcode +from io import BytesIO # Check platform and conditionally import comtypes (Windows-only for PDF conversion) IS_WINDOWS = platform.system() == 'Windows' @@ -28,6 +33,62 @@ def load_config(config_path="config.json"): with open(config_path, 'r', encoding='utf-8') as f: return json.load(f) +def generate_certificate_id(name, cert_type, issue_date): + """Generate a unique certificate ID based on name, type, and date.""" + data = f"{name}|{cert_type}|{issue_date}" + hash_obj = hashlib.sha256(data.encode('utf-8')) + return hash_obj.hexdigest()[:12].upper() + +def load_certificate_registry(registry_path="../data/output/certificate_registry.json"): + """Load existing certificate registry or create new one.""" + if os.path.exists(registry_path): + with open(registry_path, 'r', encoding='utf-8') as f: + return json.load(f) + return {"certificates": []} + +def save_certificate_registry(registry, registry_path="../data/output/certificate_registry.json"): + """Save certificate registry to file.""" + os.makedirs(os.path.dirname(registry_path), exist_ok=True) + with open(registry_path, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + +def add_to_registry(registry, cert_id, name, cert_type, issue_date): + """Add a certificate record to the registry.""" + certificate = { + "id": cert_id, + "name": name, + "type": cert_type, + "issue_date": issue_date, + "verification_url": f"https://www.womencodingcommunity.com/verify?cert={cert_id}" + } + + # Check if certificate already exists + existing = next((c for c in registry["certificates"] if c["id"] == cert_id), None) + if not existing: + registry["certificates"].append(certificate) + + return certificate + +def generate_qr_code(verification_url): + """Generate QR code image for verification URL.""" + qr = qrcode.QRCode( + version=1, + error_correction=qrcode.constants.ERROR_CORRECT_L, + box_size=10, + border=2, + ) + qr.add_data(verification_url) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + + # Save to BytesIO + img_buffer = BytesIO() + img.save(img_buffer, format='PNG') + img_buffer.seek(0) + + return img_buffer + def check_duplicates(names, cert_type): counts = Counter(names) duplicates = [name for name, count in counts.items() if count > 1] @@ -44,16 +105,20 @@ def load_names(names_file, cert_type): check_duplicates(all_names, cert_type) return set(all_names) -def generate_certificates_for_type(names, cert_config, file_type): +def generate_certificates_for_type(names, cert_config, file_type, registry=None, issue_date=None): template = cert_config['template'] input_dir = cert_config["ppt_dir"] if file_type == "pdf" else None output_dir = cert_config["ppt_dir"] if file_type == "pptx" else cert_config[ 'pdf_dir'] placeholder_text = cert_config['placeholder_text'] - font_name = cert_config['font_name'] - font_size = cert_config['font_size'] cert_type = cert_config['type'] + # Get QR code position parameters (optional) + qr_left_cm = cert_config.get('qr_left_cm') + qr_top_cm = cert_config.get('qr_top_cm') + qr_width_cm = cert_config.get('qr_width_cm', 3.0) + qr_height_cm = cert_config.get('qr_height_cm', 3.0) + os.makedirs(output_dir, exist_ok=True) print(f"Generating {cert_type.upper()} {cert_type} certificates at {output_dir}") @@ -64,8 +129,9 @@ def generate_certificates_for_type(names, cert_config, file_type): file_name = None try: if file_type == "pptx": - file_name = generate_pptx(font_name, font_size, name, output_dir, - placeholder_text, template) + file_name = generate_pptx(name, output_dir, placeholder_text, + template, cert_type, registry, issue_date, + qr_left_cm, qr_top_cm, qr_width_cm, qr_height_cm) elif file_type == "pdf": file_name = generate_pdf(name, input_dir, output_dir) @@ -80,21 +146,79 @@ def generate_certificates_for_type(names, cert_config, file_type): return file_count -def generate_pptx(font_name, font_size, name, output_dir, placeholder_text, - template): +def generate_pptx(name, output_dir, placeholder_text, template, cert_type=None, + registry=None, issue_date=None, qr_left_cm=None, qr_top_cm=None, + qr_width_cm=3.0, qr_height_cm=3.0): try: prs = Presentation(template) + + # Generate certificate ID and add to registry if provided + cert_id = None + verification_url = None + if registry is not None and issue_date is not None and cert_type is not None: + cert_id = generate_certificate_id(name, cert_type, issue_date) + cert_record = add_to_registry(registry, cert_id, name, cert_type, issue_date) + verification_url = cert_record['verification_url'] + for slide in prs.slides: for shape in slide.shapes: + # Replace name placeholder if shape.has_text_frame and shape.text.strip() == placeholder_text: tf = shape.text_frame + + # Preserve all original formatting from the placeholder + original_font = None + if tf.paragraphs and tf.paragraphs[0].runs: + original_run = tf.paragraphs[0].runs[0] + original_font = { + 'name': original_run.font.name, + 'size': original_run.font.size, + 'bold': original_run.font.bold, + 'italic': original_run.font.italic, + 'underline': original_run.font.underline, + 'color': original_run.font.color.rgb if original_run.font.color.type is not None else None + } + tf.clear() p = tf.paragraphs[0] run = p.add_run() run.text = name - run.font.name = font_name - run.font.size = Pt(font_size) + + # Apply all preserved formatting + if original_font: + if original_font['name']: + run.font.name = original_font['name'] + if original_font['size']: + run.font.size = original_font['size'] + if original_font['bold'] is not None: + run.font.bold = original_font['bold'] + if original_font['italic'] is not None: + run.font.italic = original_font['italic'] + if original_font['underline'] is not None: + run.font.underline = original_font['underline'] + if original_font['color'] is not None: + run.font.color.rgb = original_font['color'] + + # Add QR code to slide if verification URL exists + if verification_url: + qr_img = generate_qr_code(verification_url) + + # Use configured position (in cm) or default to top right corner + if qr_left_cm is not None and qr_top_cm is not None: + left = Cm(qr_left_cm) + top = Cm(qr_top_cm) + width = Cm(qr_width_cm) + height = Cm(qr_height_cm) + else: + # Default position (top right corner) + left = prs.slide_width - Inches(1.5) + top = Inches(0.3) + width = Inches(1.2) + height = Inches(1.2) + + slide.shapes.add_picture(qr_img, left, top, width, height) + pptx_path = os.path.join(output_dir, f"{name}.pptx") prs.save(pptx_path) return pptx_path @@ -161,6 +285,12 @@ def main(): try: config = load_config() + # Load certificate registry + registry = load_certificate_registry() + + # Get current issue date + issue_date = datetime.now().strftime("%Y-%m-%d") + # Initialize PowerPoint if available if POWERPOINT_AVAILABLE: powerpoint = comtypes.client.CreateObject("PowerPoint.Application") @@ -177,7 +307,9 @@ def main(): # Generate PPTX certificates pptx_generated = generate_certificates_for_type(names, cert_config, - "pptx") + "pptx", + registry, + issue_date) check_metrics(names, cert_config, "pptx") # Generate PDF certificates only if PowerPoint is available @@ -193,6 +325,10 @@ def main(): print(f"Type: {cert_config['type']} Total: {total_certificates} " f"PPTX Generated: {pptx_generated} PDF Generated: {pdf_generated}") + # Save updated registry + save_certificate_registry(registry) + print(f"\nCertificate registry saved with {len(registry['certificates'])} total certificates") + except Exception as e: print(f"Error while running the certificate generation automation:" diff --git a/tools/certificate_automation/tests/README.md b/tools/certificate_automation/tests/README.md new file mode 100644 index 00000000..c03c2472 --- /dev/null +++ b/tools/certificate_automation/tests/README.md @@ -0,0 +1,158 @@ +# WCC Certificate Automation - Test Suite + +Comprehensive test suite for the Women Coding Community certificate automation system, including QR code verification +functionality. + +## Test Structure + +``` +tests/ +├── __init__.py # Test package initialization +├── test_certificate_id.py # Unit tests for certificate ID generation +├── test_qr_code.py # Unit tests for QR code generation +├── test_registry.py # Unit tests for certificate registry +├── test_certificate_generation.py # Unit tests for PPTX generation +├── test_integration.py # Integration tests +└── README.md # This file +``` + +## Test Coverage + +### 1. Certificate ID Generation Tests (`test_certificate_id.py`) + +- ID format validation (12 characters, uppercase, hexadecimal) +- Deterministic generation (same inputs → same ID) +- Uniqueness (different inputs → different IDs) +- Special character handling +- Long name handling + +### 2. QR Code Generation Tests (`test_qr_code.py`) + +- QR code image creation +- PNG format validation +- Deterministic generation +- Different URLs generate different codes +- Image validity checks + +### 3. Certificate Registry Tests (`test_registry.py`) + +- Registry creation and loading +- Certificate addition +- Duplicate prevention +- Registry persistence (save/load) +- Unicode character support +- Multiple certificate management + +### 4. Certificate Generation Tests (`test_certificate_generation.py`) + +- PPTX file creation +- Placeholder text replacement +- QR code embedding +- Registry integration +- Name loading from files +- Duplicate name detection +- Whitespace handling + +### 5. Integration Tests (`test_integration.py`) + +- Complete workflow (ID → QR → Registry → PPTX) +- Multiple certificate batch generation +- Different certificate types +- Same name, different types +- QR code verification +- Registry persistence across sessions + +## Running Tests + +### Option 1: Using the Test Runner Script + +Run all tests: + +```bash +python run_tests.py +``` + +Run with verbose output: + +```bash +python run_tests.py -v +``` + +Run with coverage report: + +```bash +python run_tests.py --coverage +``` + +### Option 2: Using unittest + +Run all tests: + +```bash +cd tools/certificate_automation +python -m unittest discover tests +``` + +Run specific test file: + +```bash +python -m unittest tests.test_certificate_id +``` + +Run specific test class: + +```bash +python -m unittest tests.test_certificate_id.TestCertificateID +``` + +Run specific test method: + +```bash +python -m unittest tests.test_certificate_id.TestCertificateID.test_generate_certificate_id_length +``` + +### Option 3: Using pytest (Recommended) + +Install pytest: + +```bash +pip install -r requirements-dev.txt +``` + +Run all tests: + +```bash +pytest +``` + +Run with coverage: + +```bash +pytest --cov=src --cov-report=html +``` + +Run specific test file: + +```bash +pytest tests/test_certificate_id.py +``` + +Run with verbose output: + +```bash +pytest -v +``` + +## Test Requirements + +Install test dependencies: + +```bash +pip install -r requirements-dev.txt +``` + +Or install individually: + +```bash +pip install pytest pytest-cov coverage +``` \ No newline at end of file diff --git a/tools/certificate_automation/tests/__init__.py b/tools/certificate_automation/tests/__init__.py new file mode 100644 index 00000000..d8fffae3 --- /dev/null +++ b/tools/certificate_automation/tests/__init__.py @@ -0,0 +1 @@ +# Tests for WCC Certificate Automation diff --git a/tools/certificate_automation/tests/test_certificate_generation.py b/tools/certificate_automation/tests/test_certificate_generation.py new file mode 100644 index 00000000..743c0332 --- /dev/null +++ b/tools/certificate_automation/tests/test_certificate_generation.py @@ -0,0 +1,258 @@ +import unittest +import sys +import os +import tempfile +import shutil +from unittest.mock import Mock, patch, MagicMock +from pptx import Presentation +from pptx.util import Inches, Pt + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from generate_certificates import ( + generate_pptx, + check_duplicates, + load_names +) + + +class TestCertificateGeneration(unittest.TestCase): + """Test cases for certificate generation functionality.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = tempfile.mkdtemp() + self.output_dir = os.path.join(self.test_dir, 'output') + os.makedirs(self.output_dir, exist_ok=True) + + # Create a simple test template + self.template_path = os.path.join(self.test_dir, 'template.pptx') + self._create_test_template() + + def tearDown(self): + """Tear down test fixtures.""" + shutil.rmtree(self.test_dir) + + def _create_test_template(self): + """Create a simple PPTX template for testing.""" + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(7.5) + + slide_layout = prs.slide_layouts[6] # Blank layout + slide = prs.slides.add_slide(slide_layout) + + # Add a text box with placeholder text + left = Inches(2) + top = Inches(3) + width = Inches(6) + height = Inches(1) + + textbox = slide.shapes.add_textbox(left, top, width, height) + text_frame = textbox.text_frame + text_frame.text = "Sample Sample" + + prs.save(self.template_path) + + def test_generate_pptx_creates_file(self): + """Test that generate_pptx creates output file.""" + name = "John Smith" + registry = {"certificates": []} + issue_date = "2026-01-04" + + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="mentee", + registry=registry, + issue_date=issue_date + ) + + # Verify file was created + self.assertTrue(os.path.exists(result)) + self.assertTrue(result.endswith('.pptx')) + + def test_generate_pptx_replaces_placeholder(self): + """Test that placeholder text is replaced with name.""" + name = "Jane Doe" + registry = {"certificates": []} + issue_date = "2026-01-04" + + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="mentor", + registry=registry, + issue_date=issue_date + ) + + # Open the generated file and check content + prs = Presentation(result) + slide = prs.slides[0] + + # Find text in shapes + found_name = False + for shape in slide.shapes: + if shape.has_text_frame: + if name in shape.text: + found_name = True + break + + self.assertTrue(found_name, f"Name '{name}' not found in generated certificate") + + def test_generate_pptx_adds_to_registry(self): + """Test that certificate is added to registry.""" + name = "Alice Johnson" + registry = {"certificates": []} + issue_date = "2026-01-04" + + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="volunteer", + registry=registry, + issue_date=issue_date + ) + + # Verify certificate was added to registry + self.assertEqual(len(registry['certificates']), 1) + self.assertEqual(registry['certificates'][0]['name'], name) + self.assertEqual(registry['certificates'][0]['type'], 'volunteer') + + def test_generate_pptx_adds_qr_code(self): + """Test that QR code is added to certificate.""" + name = "Bob Smith" + registry = {"certificates": []} + issue_date = "2026-01-04" + + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="mentee", + registry=registry, + issue_date=issue_date + ) + + # Open the generated file + prs = Presentation(result) + slide = prs.slides[0] + + # Count image shapes (QR code should be one) + image_count = sum(1 for shape in slide.shapes if shape.shape_type == 13) # 13 = PICTURE + + self.assertGreater(image_count, 0, "No QR code image found in certificate") + + def test_generate_pptx_without_registry(self): + """Test that generate_pptx works without registry (backwards compatibility).""" + name = "Legacy User" + + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path + ) + + # Should create file without error + self.assertTrue(os.path.exists(result)) + + def test_check_duplicates_no_duplicates(self): + """Test check_duplicates with unique names.""" + names = ["John Smith", "Jane Doe", "Alice Johnson"] + + # Should run without raising exception + try: + check_duplicates(names, "mentee") + except Exception as e: + self.fail(f"check_duplicates raised exception: {e}") + + def test_check_duplicates_with_duplicates(self): + """Test check_duplicates identifies duplicates.""" + names = ["John Smith", "Jane Doe", "John Smith", "Alice Johnson"] + + # Capture output + import io + from contextlib import redirect_stdout + + f = io.StringIO() + with redirect_stdout(f): + check_duplicates(names, "mentee") + + output = f.getvalue() + + # Should print warning about duplicates + self.assertIn("WARNING", output) + self.assertIn("John Smith", output) + + def test_load_names_from_file(self): + """Test loading names from file.""" + # Create test names file + names_file = os.path.join(self.test_dir, 'names.txt') + with open(names_file, 'w', encoding='utf-8') as f: + f.write("John Smith\n") + f.write("Jane Doe\n") + f.write("Alice Johnson\n") + + names = load_names(names_file, "mentee") + + self.assertEqual(len(names), 3) + self.assertIn("John Smith", names) + self.assertIn("Jane Doe", names) + self.assertIn("Alice Johnson", names) + + def test_load_names_removes_duplicates(self): + """Test that load_names returns unique names.""" + # Create test names file with duplicates + names_file = os.path.join(self.test_dir, 'names_dup.txt') + with open(names_file, 'w', encoding='utf-8') as f: + f.write("John Smith\n") + f.write("Jane Doe\n") + f.write("John Smith\n") # Duplicate + + names = load_names(names_file, "mentee") + + # Should return set with unique names + self.assertEqual(len(names), 2) + + def test_load_names_strips_whitespace(self): + """Test that load_names strips whitespace from names.""" + # Create test names file with extra whitespace + names_file = os.path.join(self.test_dir, 'names_ws.txt') + with open(names_file, 'w', encoding='utf-8') as f: + f.write(" John Smith \n") + f.write("\tJane Doe\n") + f.write("Alice Johnson \n") + + names = load_names(names_file, "mentee") + + self.assertIn("John Smith", names) + self.assertIn("Jane Doe", names) + self.assertIn("Alice Johnson", names) + + def test_load_names_skips_empty_lines(self): + """Test that load_names skips empty lines.""" + # Create test names file with empty lines + names_file = os.path.join(self.test_dir, 'names_empty.txt') + with open(names_file, 'w', encoding='utf-8') as f: + f.write("John Smith\n") + f.write("\n") + f.write("Jane Doe\n") + f.write(" \n") + f.write("Alice Johnson\n") + + names = load_names(names_file, "mentee") + + self.assertEqual(len(names), 3) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/certificate_automation/tests/test_certificate_id.py b/tools/certificate_automation/tests/test_certificate_id.py new file mode 100644 index 00000000..00ab9a83 --- /dev/null +++ b/tools/certificate_automation/tests/test_certificate_id.py @@ -0,0 +1,74 @@ +import unittest +import sys +import os + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from generate_certificates import generate_certificate_id + + +class TestCertificateID(unittest.TestCase): + """Test cases for certificate ID generation.""" + + def test_generate_certificate_id_returns_string(self): + """Test that certificate ID is returned as a string.""" + cert_id = generate_certificate_id("John Smith", "mentee", "2026-01-04") + self.assertIsInstance(cert_id, str) + + def test_generate_certificate_id_length(self): + """Test that certificate ID has correct length (12 characters).""" + cert_id = generate_certificate_id("John Smith", "mentee", "2026-01-04") + self.assertEqual(len(cert_id), 12) + + def test_generate_certificate_id_uppercase(self): + """Test that certificate ID is uppercase.""" + cert_id = generate_certificate_id("John Smith", "mentee", "2026-01-04") + self.assertEqual(cert_id, cert_id.upper()) + + def test_generate_certificate_id_deterministic(self): + """Test that same inputs generate same ID.""" + cert_id1 = generate_certificate_id("John Smith", "mentee", "2026-01-04") + cert_id2 = generate_certificate_id("John Smith", "mentee", "2026-01-04") + self.assertEqual(cert_id1, cert_id2) + + def test_generate_certificate_id_unique_for_different_names(self): + """Test that different names generate different IDs.""" + cert_id1 = generate_certificate_id("John Smith", "mentee", "2026-01-04") + cert_id2 = generate_certificate_id("Jane Doe", "mentee", "2026-01-04") + self.assertNotEqual(cert_id1, cert_id2) + + def test_generate_certificate_id_unique_for_different_types(self): + """Test that different certificate types generate different IDs.""" + cert_id1 = generate_certificate_id("John Smith", "mentee", "2026-01-04") + cert_id2 = generate_certificate_id("John Smith", "mentor", "2026-01-04") + self.assertNotEqual(cert_id1, cert_id2) + + def test_generate_certificate_id_unique_for_different_dates(self): + """Test that different dates generate different IDs.""" + cert_id1 = generate_certificate_id("John Smith", "mentee", "2026-01-04") + cert_id2 = generate_certificate_id("John Smith", "mentee", "2026-01-05") + self.assertNotEqual(cert_id1, cert_id2) + + def test_generate_certificate_id_with_special_characters(self): + """Test certificate ID generation with special characters in name.""" + cert_id = generate_certificate_id("María José", "mentee", "2026-01-04") + self.assertIsInstance(cert_id, str) + self.assertEqual(len(cert_id), 12) + + def test_generate_certificate_id_with_long_name(self): + """Test certificate ID generation with very long name.""" + long_name = "Christopher Alexander Montgomery-Wellington III" + cert_id = generate_certificate_id(long_name, "mentee", "2026-01-04") + self.assertIsInstance(cert_id, str) + self.assertEqual(len(cert_id), 12) + + def test_generate_certificate_id_hexadecimal(self): + """Test that certificate ID contains only hexadecimal characters.""" + cert_id = generate_certificate_id("John Smith", "mentee", "2026-01-04") + valid_chars = set("0123456789ABCDEF") + self.assertTrue(all(c in valid_chars for c in cert_id)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/certificate_automation/tests/test_integration.py b/tools/certificate_automation/tests/test_integration.py new file mode 100644 index 00000000..fee64179 --- /dev/null +++ b/tools/certificate_automation/tests/test_integration.py @@ -0,0 +1,311 @@ +import unittest +import sys +import os +import tempfile +import shutil +from pptx import Presentation +from pptx.util import Inches, Pt + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from generate_certificates import ( + generate_certificate_id, + generate_qr_code, + load_certificate_registry, + save_certificate_registry, + add_to_registry, + generate_pptx +) + + +class TestIntegration(unittest.TestCase): + """Integration tests for complete certificate generation workflow.""" + + def setUp(self): + """Set up test fixtures.""" + self.test_dir = tempfile.mkdtemp() + self.output_dir = os.path.join(self.test_dir, 'output') + os.makedirs(self.output_dir, exist_ok=True) + + self.template_path = os.path.join(self.test_dir, 'template.pptx') + self.registry_path = os.path.join(self.test_dir, 'registry.json') + + self._create_test_template() + + def tearDown(self): + """Tear down test fixtures.""" + shutil.rmtree(self.test_dir) + + def _create_test_template(self): + """Create a simple PPTX template for testing.""" + prs = Presentation() + prs.slide_width = Inches(10) + prs.slide_height = Inches(7.5) + + slide_layout = prs.slide_layouts[6] + slide = prs.slides.add_slide(slide_layout) + + left = Inches(2) + top = Inches(3) + width = Inches(6) + height = Inches(1) + + textbox = slide.shapes.add_textbox(left, top, width, height) + text_frame = textbox.text_frame + text_frame.text = "Sample Sample" + + prs.save(self.template_path) + + def test_complete_certificate_workflow(self): + """Test complete workflow: generate ID, create QR, add to registry, create PPTX.""" + name = "John Smith" + cert_type = "mentee" + issue_date = "2026-01-04" + + # Step 1: Load/create registry + registry = load_certificate_registry(self.registry_path) + self.assertEqual(len(registry['certificates']), 0) + + # Step 2: Generate certificate + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + + # Step 3: Verify PPTX was created + self.assertTrue(os.path.exists(result)) + + # Step 4: Verify registry was updated + self.assertEqual(len(registry['certificates']), 1) + cert_record = registry['certificates'][0] + + self.assertEqual(cert_record['name'], name) + self.assertEqual(cert_record['type'], cert_type) + self.assertEqual(cert_record['issue_date'], issue_date) + self.assertIn('id', cert_record) + self.assertIn('verification_url', cert_record) + + # Step 5: Save registry + save_certificate_registry(registry, self.registry_path) + + # Step 6: Verify registry file was created + self.assertTrue(os.path.exists(self.registry_path)) + + # Step 7: Load registry again and verify + loaded_registry = load_certificate_registry(self.registry_path) + self.assertEqual(len(loaded_registry['certificates']), 1) + self.assertEqual(loaded_registry['certificates'][0]['name'], name) + + def test_multiple_certificates_workflow(self): + """Test generating multiple certificates in one batch.""" + names = ["Alice Johnson", "Bob Smith", "Carol White"] + cert_type = "mentor" + issue_date = "2026-01-04" + + registry = load_certificate_registry(self.registry_path) + + # Generate certificates for all names + for name in names: + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + + # Verify all certificates were created + generated_files = os.listdir(self.output_dir) + self.assertEqual(len(generated_files), 3) + + # Verify all certificates are in registry + self.assertEqual(len(registry['certificates']), 3) + + # Verify each name is in registry + registry_names = [cert['name'] for cert in registry['certificates']] + for name in names: + self.assertIn(name, registry_names) + + # Verify all IDs are unique + cert_ids = [cert['id'] for cert in registry['certificates']] + self.assertEqual(len(cert_ids), len(set(cert_ids))) + + def test_different_certificate_types(self): + """Test generating different types of certificates.""" + test_cases = [ + ("User1", "mentee", "2026-01-04"), + ("User2", "mentor", "2026-01-04"), + ("User3", "volunteer", "2026-01-04"), + ("User4", "leader", "2026-01-04") + ] + + registry = load_certificate_registry(self.registry_path) + + for name, cert_type, issue_date in test_cases: + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + + # Verify all types are present in registry + cert_types = [cert['type'] for cert in registry['certificates']] + self.assertIn("mentee", cert_types) + self.assertIn("mentor", cert_types) + self.assertIn("volunteer", cert_types) + self.assertIn("leader", cert_types) + + def test_same_name_different_types_different_ids(self): + """Test that same name with different types gets different IDs.""" + name = "John Smith" + issue_date = "2026-01-04" + + registry = load_certificate_registry(self.registry_path) + + # Generate mentee certificate + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="mentee", + registry=registry, + issue_date=issue_date + ) + + # Generate mentor certificate for same person + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type="mentor", + registry=registry, + issue_date=issue_date + ) + + # Should have 2 certificates with different IDs + self.assertEqual(len(registry['certificates']), 2) + + id1 = registry['certificates'][0]['id'] + id2 = registry['certificates'][1]['id'] + + self.assertNotEqual(id1, id2) + + def test_qr_code_in_generated_certificate(self): + """Test that QR code is actually embedded in the generated certificate.""" + name = "QR Test User" + cert_type = "mentee" + issue_date = "2026-01-04" + + registry = load_certificate_registry(self.registry_path) + + result = generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + + # Open generated certificate + prs = Presentation(result) + slide = prs.slides[0] + + # Count images (should have QR code) + image_count = sum(1 for shape in slide.shapes if shape.shape_type == 13) + + self.assertGreater(image_count, 0, "No QR code found in certificate") + + # Verify certificate is in registry + cert_id = registry['certificates'][0]['id'] + self.assertIsNotNone(cert_id) + self.assertEqual(len(cert_id), 12) + + def test_verification_url_contains_cert_id(self): + """Test that verification URL contains the correct certificate ID.""" + name = "URL Test User" + cert_type = "mentor" + issue_date = "2026-01-04" + + registry = load_certificate_registry(self.registry_path) + + generate_pptx( + name=name, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + + cert = registry['certificates'][0] + cert_id = cert['id'] + verification_url = cert['verification_url'] + + # Verify URL contains certificate ID + self.assertIn(cert_id, verification_url) + self.assertIn("womencodingcommunity.com", verification_url) + self.assertIn("verify?cert=", verification_url) + + def test_registry_persistence(self): + """Test that registry persists across multiple save/load cycles.""" + name1 = "Persistent User 1" + name2 = "Persistent User 2" + cert_type = "mentee" + issue_date = "2026-01-04" + + # First batch + registry = load_certificate_registry(self.registry_path) + generate_pptx( + name=name1, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + save_certificate_registry(registry, self.registry_path) + + # Second batch - load existing registry + registry = load_certificate_registry(self.registry_path) + self.assertEqual(len(registry['certificates']), 1) + + generate_pptx( + name=name2, + output_dir=self.output_dir, + placeholder_text="Sample Sample", + template=self.template_path, + cert_type=cert_type, + registry=registry, + issue_date=issue_date + ) + save_certificate_registry(registry, self.registry_path) + + # Load final registry + final_registry = load_certificate_registry(self.registry_path) + self.assertEqual(len(final_registry['certificates']), 2) + + names = [cert['name'] for cert in final_registry['certificates']] + self.assertIn(name1, names) + self.assertIn(name2, names) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/certificate_automation/tests/test_publish_registry.py b/tools/certificate_automation/tests/test_publish_registry.py new file mode 100644 index 00000000..e0a508e4 --- /dev/null +++ b/tools/certificate_automation/tests/test_publish_registry.py @@ -0,0 +1,153 @@ +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts')) + +from publish_registry import load_registry, merge_registries, save_registry + + +class TestPublishRegistry(unittest.TestCase): + + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.temp_path = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_load_registry_existing_file(self): + registry_file = self.temp_path / "registry.json" + test_data = {"certificates": [{"id": "ABC123", "name": "Test User"}]} + + with open(registry_file, 'w') as f: + json.dump(test_data, f) + + result = load_registry(registry_file) + self.assertEqual(result, test_data) + + def test_load_registry_nonexistent_file(self): + result = load_registry(self.temp_path / "nonexistent.json") + self.assertEqual(result, {"certificates": []}) + + def test_load_registry_invalid_json(self): + registry_file = self.temp_path / "invalid.json" + + with open(registry_file, 'w') as f: + f.write("invalid json{") + + result = load_registry(registry_file) + self.assertEqual(result, {"certificates": []}) + + def test_save_registry(self): + registry_file = self.temp_path / "output" / "registry.json" + test_data = {"certificates": [{"id": "XYZ789", "name": "Saved User"}]} + + save_registry(test_data, registry_file) + + self.assertTrue(registry_file.exists()) + + with open(registry_file, 'r') as f: + loaded = json.load(f) + + self.assertEqual(loaded, test_data) + + def test_merge_registries_no_duplicates(self): + existing = { + "certificates": [ + {"id": "AAA111", "name": "Alice", "type": "mentee"}, + {"id": "BBB222", "name": "Bob", "type": "mentor"} + ] + } + + new = { + "certificates": [ + {"id": "CCC333", "name": "Charlie", "type": "volunteer"} + ] + } + + merged, added, skipped = merge_registries(existing, new) + + self.assertEqual(len(merged['certificates']), 3) + self.assertEqual(added, 1) + self.assertEqual(skipped, 0) + + def test_merge_registries_with_duplicates(self): + existing = { + "certificates": [ + {"id": "AAA111", "name": "Alice", "type": "mentee"}, + {"id": "BBB222", "name": "Bob", "type": "mentor"} + ] + } + + new = { + "certificates": [ + {"id": "AAA111", "name": "Alice Updated", "type": "mentee"}, + {"id": "CCC333", "name": "Charlie", "type": "volunteer"} + ] + } + + merged, added, skipped = merge_registries(existing, new) + + self.assertEqual(len(merged['certificates']), 3) + self.assertEqual(added, 1) + self.assertEqual(skipped, 1) + + alice = next(c for c in merged['certificates'] if c['id'] == 'AAA111') + self.assertEqual(alice['name'], 'Alice') + + def test_merge_registries_empty_existing(self): + existing = {"certificates": []} + + new = { + "certificates": [ + {"id": "AAA111", "name": "Alice", "type": "mentee"} + ] + } + + merged, added, skipped = merge_registries(existing, new) + + self.assertEqual(len(merged['certificates']), 1) + self.assertEqual(added, 1) + self.assertEqual(skipped, 0) + + def test_merge_registries_empty_new(self): + existing = { + "certificates": [ + {"id": "AAA111", "name": "Alice", "type": "mentee"} + ] + } + + new = {"certificates": []} + + merged, added, skipped = merge_registries(existing, new) + + self.assertEqual(len(merged['certificates']), 1) + self.assertEqual(added, 0) + self.assertEqual(skipped, 0) + + def test_merge_registries_sorts_by_name(self): + existing = { + "certificates": [ + {"id": "ZZZ999", "name": "Zoe", "type": "mentee"} + ] + } + + new = { + "certificates": [ + {"id": "AAA111", "name": "Alice", "type": "mentor"}, + {"id": "MMM555", "name": "Mike", "type": "volunteer"} + ] + } + + merged, _, _ = merge_registries(existing, new) + + names = [c['name'] for c in merged['certificates']] + self.assertEqual(names, ['Alice', 'Mike', 'Zoe']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/certificate_automation/tests/test_qr_code.py b/tools/certificate_automation/tests/test_qr_code.py new file mode 100644 index 00000000..70b4d709 --- /dev/null +++ b/tools/certificate_automation/tests/test_qr_code.py @@ -0,0 +1,103 @@ +import unittest +import sys +import os +from io import BytesIO +from PIL import Image + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from generate_certificates import generate_qr_code + + +class TestQRCode(unittest.TestCase): + """Test cases for QR code generation.""" + + def test_generate_qr_code_returns_bytesio(self): + """Test that QR code generation returns BytesIO object.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + qr_img = generate_qr_code(url) + self.assertIsInstance(qr_img, BytesIO) + + def test_generate_qr_code_creates_valid_image(self): + """Test that QR code is a valid image.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + qr_img = generate_qr_code(url) + + # Try to open as image + try: + img = Image.open(qr_img) + self.assertIsNotNone(img) + except Exception as e: + self.fail(f"Generated QR code is not a valid image: {e}") + + def test_generate_qr_code_image_format(self): + """Test that QR code is in PNG format.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + qr_img = generate_qr_code(url) + + img = Image.open(qr_img) + self.assertEqual(img.format, 'PNG') + + def test_generate_qr_code_image_mode(self): + """Test that QR code image has correct color mode.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + qr_img = generate_qr_code(url) + + img = Image.open(qr_img) + # QR codes should be in RGB or similar mode + self.assertIn(img.mode, ['RGB', 'L', '1']) + + def test_generate_qr_code_with_different_urls(self): + """Test that different URLs generate different QR codes.""" + url1 = "https://www.womencodingcommunity.com/verify?cert=ABC123" + url2 = "https://www.womencodingcommunity.com/verify?cert=DEF456" + + qr_img1 = generate_qr_code(url1) + qr_img2 = generate_qr_code(url2) + + # Read image data + img1_data = qr_img1.getvalue() + img2_data = qr_img2.getvalue() + + # Different URLs should generate different QR codes + self.assertNotEqual(img1_data, img2_data) + + def test_generate_qr_code_deterministic(self): + """Test that same URL generates same QR code.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + + qr_img1 = generate_qr_code(url) + qr_img2 = generate_qr_code(url) + + # Read image data + img1_data = qr_img1.getvalue() + img2_data = qr_img2.getvalue() + + # Same URL should generate same QR code + self.assertEqual(img1_data, img2_data) + + def test_generate_qr_code_with_long_url(self): + """Test QR code generation with very long URL.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456&extra=verylongparametervalue" + qr_img = generate_qr_code(url) + + # Should still generate valid image + img = Image.open(qr_img) + self.assertIsNotNone(img) + + def test_generate_qr_code_image_not_empty(self): + """Test that generated QR code image is not empty.""" + url = "https://www.womencodingcommunity.com/verify?cert=ABC123DEF456" + qr_img = generate_qr_code(url) + + img = Image.open(qr_img) + width, height = img.size + + # Image should have reasonable size + self.assertGreater(width, 0) + self.assertGreater(height, 0) + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/certificate_automation/tests/test_registry.py b/tools/certificate_automation/tests/test_registry.py new file mode 100644 index 00000000..948962e8 --- /dev/null +++ b/tools/certificate_automation/tests/test_registry.py @@ -0,0 +1,209 @@ +import unittest +import sys +import os +import json +import tempfile +import shutil + +# Add src directory to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src')) + +from generate_certificates import ( + load_certificate_registry, + save_certificate_registry, + add_to_registry +) + + +class TestCertificateRegistry(unittest.TestCase): + """Test cases for certificate registry management.""" + + def setUp(self): + """Set up test fixtures.""" + # Create temporary directory for test files + self.test_dir = tempfile.mkdtemp() + self.registry_path = os.path.join(self.test_dir, 'test_registry.json') + + def tearDown(self): + """Tear down test fixtures.""" + # Remove temporary directory + shutil.rmtree(self.test_dir) + + def test_load_certificate_registry_new_file(self): + """Test loading registry when file doesn't exist.""" + registry = load_certificate_registry(self.registry_path) + + self.assertIsInstance(registry, dict) + self.assertIn('certificates', registry) + self.assertIsInstance(registry['certificates'], list) + self.assertEqual(len(registry['certificates']), 0) + + def test_load_certificate_registry_existing_file(self): + """Test loading registry from existing file.""" + # Create a test registry file + test_data = { + "certificates": [ + { + "id": "ABC123", + "name": "Test User", + "type": "mentee", + "issue_date": "2026-01-04", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=ABC123" + } + ] + } + + with open(self.registry_path, 'w', encoding='utf-8') as f: + json.dump(test_data, f) + + # Load the registry + registry = load_certificate_registry(self.registry_path) + + self.assertEqual(len(registry['certificates']), 1) + self.assertEqual(registry['certificates'][0]['id'], 'ABC123') + self.assertEqual(registry['certificates'][0]['name'], 'Test User') + + def test_save_certificate_registry(self): + """Test saving registry to file.""" + registry = { + "certificates": [ + { + "id": "TEST123", + "name": "Jane Doe", + "type": "mentor", + "issue_date": "2026-01-04", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=TEST123" + } + ] + } + + save_certificate_registry(registry, self.registry_path) + + # Verify file was created + self.assertTrue(os.path.exists(self.registry_path)) + + # Verify content + with open(self.registry_path, 'r', encoding='utf-8') as f: + saved_data = json.load(f) + + self.assertEqual(len(saved_data['certificates']), 1) + self.assertEqual(saved_data['certificates'][0]['id'], 'TEST123') + + def test_add_to_registry_new_certificate(self): + """Test adding a new certificate to registry.""" + registry = {"certificates": []} + + cert = add_to_registry( + registry, + cert_id="NEW123", + name="Alice Johnson", + cert_type="volunteer", + issue_date="2026-01-04" + ) + + self.assertEqual(len(registry['certificates']), 1) + self.assertEqual(cert['id'], 'NEW123') + self.assertEqual(cert['name'], 'Alice Johnson') + self.assertEqual(cert['type'], 'volunteer') + self.assertEqual(cert['issue_date'], '2026-01-04') + self.assertIn('verification_url', cert) + + def test_add_to_registry_duplicate_certificate(self): + """Test that duplicate certificates are not added.""" + registry = {"certificates": []} + + # Add first certificate + add_to_registry( + registry, + cert_id="DUP123", + name="Bob Smith", + cert_type="mentee", + issue_date="2026-01-04" + ) + + # Try to add duplicate + add_to_registry( + registry, + cert_id="DUP123", + name="Bob Smith", + cert_type="mentee", + issue_date="2026-01-04" + ) + + # Should still only have one certificate + self.assertEqual(len(registry['certificates']), 1) + + def test_add_to_registry_verification_url_format(self): + """Test that verification URL has correct format.""" + registry = {"certificates": []} + + cert = add_to_registry( + registry, + cert_id="URL123", + name="Test User", + cert_type="mentee", + issue_date="2026-01-04" + ) + + expected_url = "https://www.womencodingcommunity.com/verify?cert=URL123" + self.assertEqual(cert['verification_url'], expected_url) + + def test_add_to_registry_multiple_certificates(self): + """Test adding multiple different certificates.""" + registry = {"certificates": []} + + add_to_registry(registry, "CERT1", "User 1", "mentee", "2026-01-04") + add_to_registry(registry, "CERT2", "User 2", "mentor", "2026-01-04") + add_to_registry(registry, "CERT3", "User 3", "volunteer", "2026-01-04") + + self.assertEqual(len(registry['certificates']), 3) + + # Verify all certificates are present + cert_ids = [cert['id'] for cert in registry['certificates']] + self.assertIn("CERT1", cert_ids) + self.assertIn("CERT2", cert_ids) + self.assertIn("CERT3", cert_ids) + + def test_save_registry_preserves_unicode(self): + """Test that registry saves unicode characters correctly.""" + registry = { + "certificates": [ + { + "id": "UNI123", + "name": "María José González", + "type": "mentee", + "issue_date": "2026-01-04", + "verification_url": "https://www.womencodingcommunity.com/verify?cert=UNI123" + } + ] + } + + save_certificate_registry(registry, self.registry_path) + + # Load and verify + with open(self.registry_path, 'r', encoding='utf-8') as f: + saved_data = json.load(f) + + self.assertEqual(saved_data['certificates'][0]['name'], "María José González") + + def test_registry_round_trip(self): + """Test saving and loading registry maintains data integrity.""" + original_registry = {"certificates": []} + + add_to_registry(original_registry, "RT1", "Round Trip User 1", "mentee", "2026-01-04") + add_to_registry(original_registry, "RT2", "Round Trip User 2", "mentor", "2026-01-05") + + # Save + save_certificate_registry(original_registry, self.registry_path) + + # Load + loaded_registry = load_certificate_registry(self.registry_path) + + # Verify + self.assertEqual(len(loaded_registry['certificates']), 2) + self.assertEqual(loaded_registry['certificates'][0]['id'], 'RT1') + self.assertEqual(loaded_registry['certificates'][1]['id'], 'RT2') + + +if __name__ == '__main__': + unittest.main() diff --git a/tools/tests/certificate_automation_test.py b/tools/tests/certificate_automation_test.py index c643d245..dba1738a 100644 --- a/tools/tests/certificate_automation_test.py +++ b/tools/tests/certificate_automation_test.py @@ -34,9 +34,7 @@ def test_load_config_returns_dict(self, tmp_path): "names_file": "names.txt", "pdf_dir": "pdfs/", "ppt_dir": "ppts/", - "placeholder_text": "Sample", - "font_name": "Arial", - "font_size": 50 + "placeholder_text": "Sample" } ] } @@ -51,8 +49,6 @@ def test_load_config_returns_dict(self, tmp_path): assert len(result["certificate_types"]) == 1 assert result["certificate_types"][0]["type"] == "test" assert result["certificate_types"][0]["placeholder_text"] == "Sample" - assert result["certificate_types"][0]["font_name"] == "Arial" - assert result["certificate_types"][0]["font_size"] == 50 assert result["certificate_types"][0]["template"] == "template.pptx" assert result["certificate_types"][0]["names_file"] == "names.txt" assert result["certificate_types"][0]["pdf_dir"] == "pdfs/" @@ -133,7 +129,7 @@ def test_generate_pptx_creates_file(self, mock_presentation_class, tmp_path): output_dir = str(tmp_path) template = "template.pptx" - result = generate_pptx("Arial", 50, "John Doe", output_dir, "Sample Sample", template) + result = generate_pptx("John Doe", output_dir, "Sample Sample", template) assert result == os.path.join(output_dir, "John Doe.pptx") mock_prs.save.assert_called_once_with(os.path.join(output_dir, "John Doe.pptx")) @@ -151,19 +147,28 @@ def test_generate_pptx_replaces_placeholder(self, mock_presentation_class): mock_text_frame = MagicMock() mock_paragraph = MagicMock() - mock_run = MagicMock() - mock_paragraph.add_run.return_value = mock_run - mock_text_frame.paragraphs = [mock_paragraph] + mock_original_run = MagicMock() + mock_original_run.font.name = "Arial" + mock_original_run.font.size = 50 + mock_original_run.font.bold = False + mock_original_run.font.italic = False + mock_original_run.font.underline = False + mock_original_run.font.color.type = None + mock_text_frame.paragraphs = [MagicMock(runs=[mock_original_run])] + + mock_new_run = MagicMock() + mock_paragraph.add_run.return_value = mock_new_run + mock_text_frame.paragraphs[0].add_run.return_value = mock_new_run + mock_shape.text_frame = mock_text_frame mock_slide.shapes = [mock_shape] mock_prs.slides = [mock_slide] - generate_pptx("Arial", 50, "Jane Smith", "/tmp", "Sample Sample", "template.pptx") + generate_pptx("Jane Smith", "/tmp", "Sample Sample", "template.pptx") mock_text_frame.clear.assert_called_once() - assert mock_run.text == "Jane Smith" - assert mock_run.font.name == "Arial" + assert mock_new_run.text == "Jane Smith" @patch('generate_certificates.Presentation') def test_generate_pptx_handles_exceptions(self, mock_presentation_class): @@ -171,7 +176,7 @@ def test_generate_pptx_handles_exceptions(self, mock_presentation_class): mock_presentation_class.side_effect = Exception("Template not found") with pytest.raises(Exception) as exc_info: - generate_pptx("Arial", 50, "John Doe", "/tmp", "Sample", "bad_template.pptx") + generate_pptx("John Doe", "/tmp", "Sample", "bad_template.pptx") assert "Template not found" in str(exc_info.value) diff --git a/tools/tests/download_image_test.py b/tools/tests/download_image_test.py index 1c644a58..2763b580 100644 --- a/tools/tests/download_image_test.py +++ b/tools/tests/download_image_test.py @@ -66,7 +66,7 @@ def test_filename_sanitization(self, tmp_path, monkeypatch): class TestRunAutomation: def test_run_automation_success(self, tmp_path, monkeypatch, caplog): caplog.set_level("INFO") - monkeypatch.setattr(sys, "argv", ["download_image.py", "tools/samples/mentors.xlsx"]) + monkeypatch.setattr(sys, "argv", ["download_image.py", "samples/mentors.xlsx"]) fake_path = str(tmp_path / "success-download.jpeg") monkeypatch.setattr(download_image, "download_image", mock.Mock(return_value=fake_path)) @@ -78,7 +78,7 @@ def test_run_automation_success(self, tmp_path, monkeypatch, caplog): def test_run_automation_failure(self, monkeypatch, caplog): caplog.set_level("INFO") - monkeypatch.setattr(sys, "argv", ["download_image.py", "tools/samples/mentors.xlsx"]) + monkeypatch.setattr(sys, "argv", ["download_image.py", "samples/mentors.xlsx"]) monkeypatch.setattr(download_image, "download_image", mock.Mock(return_value=None)) download_image.run_automation() diff --git a/tools/tests/file_utils_test.py b/tools/tests/file_utils_test.py index 283db815..9b51c848 100644 --- a/tools/tests/file_utils_test.py +++ b/tools/tests/file_utils_test.py @@ -4,7 +4,7 @@ def test_get_project_path_is_correct(): path = get_project_path() - assert path.endswith("WomenCodingCommunity.github.io") or path.endswith("WomenCodingCommunity.github.io\\") + assert path.endswith("WomenCodingCommunity.github.io") or path.endswith("WomenCodingCommunity.github.io\\") or path.endswith("WomenCodingCommunity.github.io/") def test_get_project_path(): diff --git a/verify.html b/verify.html new file mode 100644 index 00000000..b4d49b13 --- /dev/null +++ b/verify.html @@ -0,0 +1,38 @@ +--- +layout: default +title: Certificate Verification +body_class: page page-verify +image: /assets/images/default-seo-thumbnail.png +--- + +
+
+
+
+

Verify the authenticity of Women Coding Community certificates

+ +
+

How to Verify

+
    +
  1. Scan the QR code on the certificate with your phone camera
  2. +
  3. Or enter the Certificate ID shown on the certificate
  4. +
  5. Click "Verify" to check authenticity
  6. +
+
+ +
+ + +
+

Verifying certificate...

+
+ +
+
+
+
+
+