├── .gitignore ├── LICENSE.txt ├── README.md ├── setup.cfg ├── setup.py └── wallets ├── __init__.py ├── admin.py ├── apps.py ├── lib.py ├── migrations └── __init__.py ├── models.py ├── signals.py ├── tasks.py ├── urls.py └── views.py /.gitignore: -------------------------------------------------------------------------------- 1 | ### Python template 2 | # Byte-compiled / optimized / DLL files 3 | __pycache__/ 4 | *.py[cod] 5 | *$py.class 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | staticfiles/ 55 | 56 | # Sphinx documentation 57 | docs/_build/ 58 | 59 | # PyBuilder 60 | target/ 61 | 62 | # pyenv 63 | .python-version 64 | 65 | # celery beat schedule file 66 | celerybeat-schedule 67 | 68 | # Environments 69 | .venv 70 | venv/ 71 | ENV/ 72 | 73 | # Rope project settings 74 | .ropeproject 75 | 76 | # mkdocs documentation 77 | /site 78 | 79 | # mypy 80 | .mypy_cache/ 81 | 82 | 83 | ### Node template 84 | # Logs 85 | logs 86 | *.log 87 | npm-debug.log* 88 | yarn-debug.log* 89 | yarn-error.log* 90 | 91 | # Runtime data 92 | pids 93 | *.pid 94 | *.seed 95 | *.pid.lock 96 | 97 | # Directory for instrumented libs generated by jscoverage/JSCover 98 | lib-cov 99 | 100 | # Coverage directory used by tools like istanbul 101 | coverage 102 | 103 | # nyc test coverage 104 | .nyc_output 105 | 106 | # Bower dependency directory (https://bower.io/) 107 | bower_components 108 | 109 | # node-waf configuration 110 | .lock-wscript 111 | 112 | # Compiled binary addons (http://nodejs.org/api/addons.html) 113 | build/Release 114 | 115 | # Dependency directories 116 | node_modules/ 117 | jspm_packages/ 118 | 119 | # Typescript v1 declaration files 120 | typings/ 121 | 122 | # Optional npm cache directory 123 | .npm 124 | 125 | # Optional eslint cache 126 | .eslintcache 127 | 128 | # Optional REPL history 129 | .node_repl_history 130 | 131 | # Output of 'npm pack' 132 | *.tgz 133 | 134 | # Yarn Integrity file 135 | .yarn-integrity 136 | 137 | 138 | ### Linux template 139 | *~ 140 | 141 | # temporary files which can be created if a process still has a handle open of a deleted file 142 | .fuse_hidden* 143 | 144 | # KDE directory preferences 145 | .directory 146 | 147 | # Linux trash folder which might appear on any partition or disk 148 | .Trash-* 149 | 150 | # .nfs files are created when an open file is removed but is still being accessed 151 | .nfs* 152 | 153 | 154 | ### VisualStudioCode template 155 | .vscode/* 156 | !.vscode/settings.json 157 | !.vscode/tasks.json 158 | !.vscode/launch.json 159 | !.vscode/extensions.json 160 | 161 | 162 | # Provided default Pycharm Run/Debug Configurations should be tracked by git 163 | # In case of local modifications made by Pycharm, use update-index command 164 | # for each changed file, like this: 165 | # git update-index --assume-unchanged .idea/index_auth_service.iml 166 | ### JetBrains template 167 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 168 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 169 | 170 | # User-specific stuff: 171 | .idea/**/workspace.xml 172 | .idea/**/tasks.xml 173 | .idea/dictionaries 174 | 175 | # Sensitive or high-churn files: 176 | .idea/**/dataSources/ 177 | .idea/**/dataSources.ids 178 | .idea/**/dataSources.xml 179 | .idea/**/dataSources.local.xml 180 | .idea/**/sqlDataSources.xml 181 | .idea/**/dynamic.xml 182 | .idea/**/uiDesigner.xml 183 | 184 | # Gradle: 185 | .idea/**/gradle.xml 186 | .idea/**/libraries 187 | 188 | # CMake 189 | cmake-build-debug/ 190 | 191 | # Mongo Explorer plugin: 192 | .idea/**/mongoSettings.xml 193 | 194 | ## File-based project format: 195 | *.iws 196 | 197 | ## Plugin-specific files: 198 | 199 | # IntelliJ 200 | out/ 201 | 202 | # mpeltonen/sbt-idea plugin 203 | .idea_modules/ 204 | 205 | # JIRA plugin 206 | atlassian-ide-plugin.xml 207 | 208 | # Cursive Clojure plugin 209 | .idea/replstate.xml 210 | 211 | # Crashlytics plugin (for Android Studio and IntelliJ) 212 | com_crashlytics_export_strings.xml 213 | crashlytics.properties 214 | crashlytics-build.properties 215 | fabric.properties 216 | 217 | 218 | 219 | ### Windows template 220 | # Windows thumbnail cache files 221 | Thumbs.db 222 | ehthumbs.db 223 | ehthumbs_vista.db 224 | 225 | # Dump file 226 | *.stackdump 227 | 228 | # Folder config file 229 | Desktop.ini 230 | 231 | # Recycle Bin used on file shares 232 | $RECYCLE.BIN/ 233 | 234 | # Windows Installer files 235 | *.cab 236 | *.msi 237 | *.msm 238 | *.msp 239 | 240 | # Windows shortcuts 241 | *.lnk 242 | 243 | 244 | ### macOS template 245 | # General 246 | *.DS_Store 247 | .AppleDouble 248 | .LSOverride 249 | 250 | # Icon must end with two \r 251 | Icon 252 | 253 | # Thumbnails 254 | ._* 255 | 256 | # Files that might appear in the root of a volume 257 | .DocumentRevisions-V100 258 | .fseventsd 259 | .Spotlight-V100 260 | .TemporaryItems 261 | .Trashes 262 | .VolumeIcon.icns 263 | .com.apple.timemachine.donotpresent 264 | 265 | # Directories potentially created on remote AFP share 266 | .AppleDB 267 | .AppleDesktop 268 | Network Trash Folder 269 | Temporary Items 270 | .apdisk 271 | 272 | 273 | ### SublimeText template 274 | # Cache files for Sublime Text 275 | *.tmlanguage.cache 276 | *.tmPreferences.cache 277 | *.stTheme.cache 278 | 279 | # Workspace files are user-specific 280 | *.sublime-workspace 281 | 282 | # Project files should be checked into the repository, unless a significant 283 | # proportion of contributors will probably not be using Sublime Text 284 | # *.sublime-project 285 | 286 | # SFTP configuration file 287 | sftp-config.json 288 | 289 | # Package control specific files 290 | Package Control.last-run 291 | Package Control.ca-list 292 | Package Control.ca-bundle 293 | Package Control.system-ca-bundle 294 | Package Control.cache/ 295 | Package Control.ca-certs/ 296 | Package Control.merged-ca-bundle 297 | Package Control.user-ca-bundle 298 | oscrypto-ca-bundle.crt 299 | bh_unicode_properties.cache 300 | 301 | # Sublime-github package stores a github token in this file 302 | # https://packagecontrol.io/packages/sublime-github 303 | GitHub.sublime-settings 304 | 305 | 306 | ### Vim template 307 | # Swap 308 | [._]*.s[a-v][a-z] 309 | [._]*.sw[a-p] 310 | [._]s[a-v][a-z] 311 | [._]sw[a-p] 312 | 313 | # Session 314 | Session.vim 315 | 316 | # Temporary 317 | .netrwhist 318 | 319 | # Auto-generated tag files 320 | tags 321 | 322 | # IDEa files 323 | .idea/ -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | Copyright (c) 2020, Elivanov Alexey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # django-wallet 3 | 4 | Apple Wallet integration for a django a project 5 | 6 | ## Getting Started 7 | 8 | To get started you should copy the 'wallets' folder to the your project directory 9 | 10 | ``` 11 | git clone https://github.com/Silver3310/django-wallets 12 | cp -r django-wallets/wallets your-project-directory 13 | ``` 14 | 15 | ### Prerequisites 16 | 17 | If you want to make asynchronous pushes to users when their passes are updated you should install celery 18 | ``` 19 | pip install celery 20 | ``` 21 | 22 | ### Installing 23 | 24 | After you copied the app to your project directory, you should do the following steps 25 | 26 | Add the app to your project settings 27 | ```python 28 | INSTALLED_APPS = [ 29 | ... 30 | 'wallets', 31 | ... 32 | ] 33 | ``` 34 | ```python 35 | # Wallets management 36 | path( 37 | "/", 38 | include("index_auth_service.wallets.urls") 39 | ), 40 | ``` 41 | 42 | Define the model with your Pass from PassAbstract and add fields you need 43 | ```python 44 | from wallets.models import PassAbstract 45 | 46 | class Pass(PassAbstract): 47 | """ 48 | The pass model for Apple Wallet 49 | """ 50 | discount_card = models.ForeignKey( 51 | DiscountCard, 52 | on_delete=models.CASCADE, 53 | verbose_name=_('Discount card') 54 | ) 55 | ``` 56 | 57 | Add the necessary constants 58 | ```python 59 | # APPLE WALLET 60 | WALLET_CERTIFICATE_PATH = 'path-to-certificate-pem-file' 61 | WALLET_KEY_PATH = 'path-to-key-certificate-pem-file' 62 | WALLET_WWDR_PATH = 'path-to-wwdr-certificate-pem-file' 63 | WALLET_PASS_TYPE_ID = 'pass.com.you.pass.id' 64 | WALLET_PASS_PATH = os.path.join( 65 | 'the-path-where-you-want-store-passes', 66 | 'passes', 67 | 'pass{}.pkpass' 68 | ) 69 | WALLET_TEAM_IDENTIFIER = 'your-team-identifier' 70 | WALLET_ORGANIZATION_NAME = 'organization-name' 71 | WALLET_APN_HOST = ('gateway.push.apple.com', 2195) 72 | WALLET_ANDROID_HOST = 'https://push.walletunion.com/send' 73 | WALLET_ANDROID_API_KEY = 'get-it-in-the-official-site' 74 | WALLET_PASSWORD = 'certificate-key-passowrd' 75 | WALLET_ENABLE_NOTIFICATIONS = False # True if you want to send notifications (Celery needed for it) 76 | PASS_MODEL = 'your_app.your_model' 77 | ``` 78 | Make migrations for your model and the wallets app and migrate them 79 | ``` 80 | python manage.py makemigrations 81 | python manage.py migrate 82 | ``` 83 | 84 | ## License 85 | 86 | This project is licensed under the MIT License - see the [LICENSE.txt](LICENSE.txt) file for details -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from os import path 3 | 4 | 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | 7 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f: 8 | long_description = f.read() 9 | 10 | 11 | setup( 12 | name='django-wallet', 13 | packages=['wallets'], 14 | version='0.3', 15 | license='MIT', 16 | description='Apple Wallet integration for a django project', 17 | long_description=long_description, 18 | long_description_content_type='text/markdown', 19 | author='Elivanov Alexey', 20 | author_email='epifanov998@mail.ru', 21 | url='https://github.com/Silver3310/django-wallets', 22 | download_url='https://github.com/Silver3310/django-wallets/archive/v_02.tar.gz', 23 | keywords=['django', 'wallet', 'apple', 'pass'], 24 | install_requires=[ 25 | 'celery', 26 | ], 27 | classifiers=[ 28 | 'Development Status :: 3 - Alpha', 29 | 'Intended Audience :: Developers', 30 | 'Topic :: Software Development :: Build Tools', 31 | 'License :: OSI Approved :: MIT License', 32 | 'Programming Language :: Python :: 3', 33 | 'Programming Language :: Python :: 3.7', 34 | 'Programming Language :: Python :: 3.8', 35 | ], 36 | ) 37 | -------------------------------------------------------------------------------- /wallets/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'wallets.apps.WalletsConfig' -------------------------------------------------------------------------------- /wallets/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.utils.translation import gettext as _ 3 | 4 | from .models import Registration 5 | from .models import Device 6 | from .models import Log 7 | 8 | 9 | class DeviceAdmin(admin.ModelAdmin): 10 | list_display = ( 11 | 'device_library_identifier', 12 | 'get_brand', 13 | ) 14 | 15 | def get_brand(self, obj): 16 | if len(obj.push_token) > 100: 17 | return 'Android' 18 | else: 19 | return 'iPhone' 20 | 21 | get_brand.short_description = _('Brand') 22 | 23 | 24 | admin.site.register(Device, DeviceAdmin) 25 | 26 | admin.site.register(Log) 27 | 28 | admin.site.register(Registration) 29 | -------------------------------------------------------------------------------- /wallets/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class WalletsConfig(AppConfig): 5 | name = 'wallets' 6 | -------------------------------------------------------------------------------- /wallets/lib.py: -------------------------------------------------------------------------------- 1 | import decimal 2 | import hashlib 3 | import json 4 | import subprocess 5 | import zipfile 6 | from io import BytesIO 7 | 8 | from django.conf import settings 9 | 10 | 11 | class Alignment: 12 | LEFT = 'PKTextAlignmentLeft' 13 | CENTER = 'PKTextAlignmentCenter' 14 | RIGHT = 'PKTextAlignmentRight' 15 | JUSTIFIED = 'PKTextAlignmentJustified' 16 | NATURAL = 'PKTextAlignmentNatural' 17 | 18 | 19 | class BarcodeFormat: 20 | PDF417 = 'PKBarcodeFormatPDF417' 21 | QR = 'PKBarcodeFormatQR' 22 | AZTEC = 'PKBarcodeFormatAztec' 23 | CODE128 = 'PKBarcodeFormatCode128' 24 | 25 | 26 | class TransitType: 27 | AIR = 'PKTransitTypeAir' 28 | TRAIN = 'PKTransitTypeTrain' 29 | BUS = 'PKTransitTypeBus' 30 | BOAT = 'PKTransitTypeBoat' 31 | GENERIC = 'PKTransitTypeGeneric' 32 | 33 | 34 | class DateStyle: 35 | NONE = 'PKDateStyleNone' 36 | SHORT = 'PKDateStyleShort' 37 | MEDIUM = 'PKDateStyleMedium' 38 | LONG = 'PKDateStyleLong' 39 | FULL = 'PKDateStyleFull' 40 | 41 | 42 | class NumberStyle: 43 | DECIMAL = 'PKNumberStyleDecimal' 44 | PERCENT = 'PKNumberStylePercent' 45 | SCIENTIFIC = 'PKNumberStyleScientific' 46 | SPELLOUT = 'PKNumberStyleSpellOut' 47 | 48 | 49 | class Field: 50 | 51 | def __init__(self, key, value, label=''): 52 | 53 | self.key = key # Required. The key must be unique within the scope 54 | self.value = value # Required. Value of the field. For example, 42 55 | self.label = label # Optional. Label text for the field. 56 | # Optional. Format string for the alert text that is displayed when 57 | # the pass is updated 58 | self.change_message = '' 59 | self.text_alignment = Alignment.LEFT 60 | 61 | def json_dict(self): 62 | return self.__dict__ 63 | 64 | 65 | class DateField(Field): 66 | 67 | def __init__(self, key, value, label=''): 68 | super().__init__(key, value, label) 69 | self.date_style = DateStyle.SHORT # Style of date to display 70 | self.time_style = DateStyle.SHORT # Style of time to display 71 | # If true, the labels value is displayed as a relative date 72 | self.is_relative = False 73 | 74 | def json_dict(self): 75 | return self.__dict__ 76 | 77 | 78 | class NumberField(Field): 79 | 80 | def __init__(self, key, value, label=''): 81 | super().__init__(key, value, label) 82 | self.number_style = NumberStyle.DECIMAL # Style of date to display 83 | 84 | def json_dict(self): 85 | return self.__dict__ 86 | 87 | 88 | class CurrencyField(Field): 89 | 90 | def __init__(self, key, value, label='', currency_code=''): 91 | super().__init__(key, value, label) 92 | self.currency_code = currency_code # ISO 4217 currency code 93 | 94 | def json_dict(self): 95 | return self.__dict__ 96 | 97 | 98 | class Barcode: 99 | 100 | def __init__( 101 | self, 102 | message, 103 | format_=BarcodeFormat.PDF417, 104 | alt_text='' 105 | ): 106 | 107 | self.format = format_ 108 | # Required. Message or payload to be displayed as a barcode 109 | self.message = message 110 | # Required. Text encoding that is used to convert the message 111 | self.message_encoding = 'iso-8859-1' 112 | self.altText = alt_text # Optional. Text displayed near the barcode 113 | 114 | def json_dict(self): 115 | return self.__dict__ 116 | 117 | 118 | class Location: 119 | 120 | def __init__(self, latitude, longitude, altitude=0.0): 121 | # Required. Latitude, in degrees, of the location. 122 | try: 123 | self.latitude = float(latitude) 124 | except (ValueError, TypeError): 125 | self.latitude = 0.0 126 | # Required. Longitude, in degrees, of the location. 127 | try: 128 | self.longitude = float(longitude) 129 | except (ValueError, TypeError): 130 | self.longitude = 0.0 131 | # Optional. Altitude, in meters, of the location. 132 | try: 133 | self.altitude = float(altitude) 134 | except (ValueError, TypeError): 135 | self.altitude = 0.0 136 | # Optional. Notification distance 137 | self.distance = None 138 | # Optional. Text displayed on the lock screen when 139 | # the pass is currently near the location 140 | self.relevant_text = '' 141 | 142 | def json_dict(self): 143 | return self.__dict__ 144 | 145 | 146 | class IBeacon(object): 147 | def __init__(self, proximity_uuid, major, minor): 148 | # IBeacon data 149 | self.proximity_uuid = proximity_uuid 150 | self.major = major 151 | self.minor = minor 152 | 153 | # Optional. Text message where near the ibeacon 154 | self.relevant_text = '' 155 | 156 | def json_dict(self): 157 | return self.__dict__ 158 | 159 | 160 | class PassInformation: 161 | 162 | def __init__(self): 163 | self.header_fields = [] 164 | self.primary_fields = [] 165 | self.secondary_fields = [] 166 | self.back_fields = [] 167 | self.auxiliary_fields = [] 168 | 169 | def add_header_field(self, key, value, label): 170 | self.header_fields.append(Field(key, value, label)) 171 | 172 | def add_primary_field(self, key, value, label): 173 | self.primary_fields.append(Field(key, value, label)) 174 | 175 | def add_secondary_field(self, key, value, label): 176 | self.secondary_fields.append(Field(key, value, label)) 177 | 178 | def add_back_field(self, key, value, label): 179 | self.back_fields.append(Field(key, value, label)) 180 | 181 | def add_auxiliary_field(self, key, value, label): 182 | self.auxiliary_fields.append(Field(key, value, label)) 183 | 184 | def json_dict(self): 185 | d = {} 186 | if self.header_fields: 187 | d.update({ 188 | 'header_fields': [f.json_dict() for f in self.header_fields] 189 | }) 190 | if self.primary_fields: 191 | d.update({ 192 | 'primary_fields': [f.json_dict() for f in self.primary_fields] 193 | }) 194 | if self.secondary_fields: 195 | d.update({ 196 | 'secondary_fields': [ 197 | f.json_dict() for f in self.secondary_fields 198 | ] 199 | }) 200 | if self.back_fields: 201 | d.update({ 202 | 'back_fields': [f.json_dict() for f in self.back_fields] 203 | }) 204 | if self.auxiliary_fields: 205 | d.update({ 206 | 'auxiliary_fields': [ 207 | f.json_dict() for f in self.auxiliary_fields 208 | ] 209 | }) 210 | return d 211 | 212 | 213 | class BoardingPass(PassInformation): 214 | 215 | def __init__(self, transit_type=TransitType.AIR): 216 | super().__init__() 217 | self.transit_type = transit_type 218 | self.json_name = 'boardingPass' 219 | 220 | def json_dict(self): 221 | d = super().json_dict() 222 | d.update({'transitType': self.transit_type}) 223 | return d 224 | 225 | 226 | class Coupon(PassInformation): 227 | 228 | def __init__(self): 229 | super().__init__() 230 | self.json_name = 'coupon' 231 | 232 | 233 | class EventTicket(PassInformation): 234 | 235 | def __init__(self): 236 | super().__init__() 237 | self.json_name = 'eventTicket' 238 | 239 | 240 | class Generic(PassInformation): 241 | 242 | def __init__(self): 243 | super().__init__() 244 | self.json_name = 'generic' 245 | 246 | 247 | class StoreCard(PassInformation): 248 | 249 | def __init__(self): 250 | super().__init__() 251 | self.json_name = 'storeCard' 252 | 253 | 254 | class Pass: 255 | 256 | def __init__( 257 | self, 258 | pass_information, 259 | pass_type_identifier='', 260 | organization_name='', 261 | team_identifier='', 262 | foreground_color=None, 263 | background_color=None, 264 | label_color=None, 265 | logo_text=None, 266 | web_service_url='', 267 | authentication_token='', 268 | serial_number='', 269 | description='', 270 | format_version=1, 271 | barcode=None, 272 | suppress_strip_shine=False, 273 | locations=None, 274 | ibeacons=None, 275 | relevant_date=None, 276 | associated_store_identifiers=None, 277 | app_launch_url=None, 278 | user_info=None, 279 | expiration_date=None, 280 | voided=None 281 | ): 282 | 283 | self._files = {} # Holds the files to include in the .pkpass 284 | self._hashes = {} # Holds the SHAs of the files array 285 | 286 | # Standard Keys 287 | 288 | # Required. Team identifier of the organization that originated and 289 | # signed the pass, as issued by Apple. 290 | self.team_identifier = team_identifier 291 | # Required. Pass type identifier, as issued by Apple. The value must 292 | # correspond with your signing certificate. Used for grouping. 293 | self.pass_type_identifier = pass_type_identifier 294 | # Required. Display name of the organization that originated and 295 | # signed the pass. 296 | self.organization_name = organization_name 297 | # Required. Serial number that uniquely identifies the pass. 298 | self.serial_number = serial_number 299 | # Required. Brief description of the pass, used by the iOS 300 | # accessibility technologies. 301 | self.description = description 302 | # Required. Version of the file format. The value must be 1. 303 | self.format_version = format_version 304 | 305 | # Visual Appearance Keys 306 | # Optional. Background color of the pass 307 | self.background_color = background_color 308 | # Optional. Foreground color of the pass 309 | self.foreground_color = foreground_color 310 | self.label_color = label_color # Optional. Color of the label text 311 | self.logo_text = logo_text # Optional. Text displayed next to the logo 312 | self.barcode = barcode # Optional. Information specific to barcodes. 313 | # Optional. If true, the strip image is displayed 314 | self.suppress_strip_shine = suppress_strip_shine 315 | 316 | # Web Service Keys 317 | 318 | # Optional. If present, authenticationToken must be supplied 319 | self.web_service_url = web_service_url 320 | # The authentication token to use with the web service 321 | self.authentication_token = authentication_token 322 | 323 | # Relevance Keys 324 | 325 | # Optional. Locations where the pass is relevant. 326 | # For example, the location of your store. 327 | self.locations = locations 328 | # Optional. IBeacons data 329 | self.ibeacons = ibeacons 330 | # Optional. Date and time when the pass becomes relevant 331 | self.relevant_date = relevant_date 332 | 333 | # Optional. A list of iTunes Store item identifiers for 334 | # the associated apps. 335 | self.associated_store_identifiers = associated_store_identifiers 336 | self.app_launch_url = app_launch_url 337 | # Optional. Additional hidden data in json for the passbook 338 | self.user_info = user_info 339 | 340 | self.expiration_date = expiration_date 341 | self.voided = voided 342 | 343 | self.pass_information = pass_information 344 | 345 | # Adds file to the file array 346 | def add_file(self, name, fd): 347 | self._files[name] = fd.read() 348 | 349 | # Creates the actual .pkpass file 350 | def create( 351 | self, 352 | zip_file=None 353 | ): 354 | zip_file = settings.WALLET_PASS_PATH.format(self.serial_number) 355 | pass_json = self._create_pass_json() 356 | manifest = self._create_manifest(pass_json) 357 | signature = self._create_signature( 358 | manifest, 359 | settings.WALLET_CERTIFICATE_PATH, 360 | settings.WALLET_KEY_PATH, 361 | settings.WALLET_WWDR_PATH, 362 | settings.WALLET_PASSWORD, 363 | ) 364 | if not zip_file: 365 | zip_file = BytesIO() 366 | self._create_zip( 367 | pass_json, 368 | manifest, 369 | signature, 370 | zip_file=zip_file 371 | ) 372 | return zip_file 373 | 374 | def _create_pass_json(self): 375 | return json.dumps(self, default=pass_handler).encode('utf-8') 376 | 377 | # creates the hashes for the files and adds them into a json string. 378 | def _create_manifest(self, pass_json): 379 | # Creates SHA hashes for all files in package 380 | self._hashes['pass.json'] = hashlib.sha1(pass_json).hexdigest() 381 | for filename, filedata in self._files.items(): 382 | self._hashes[filename] = hashlib.sha1(filedata).hexdigest() 383 | return json.dumps(self._hashes).encode('utf-8') 384 | 385 | # Creates a signature and saves it 386 | @staticmethod 387 | def _create_signature( 388 | manifest, 389 | certificate, 390 | key, 391 | wwdr_certificate, 392 | password 393 | ): 394 | openssl_cmd = [ 395 | 'openssl', 396 | 'smime', 397 | '-binary', 398 | '-sign', 399 | '-certfile', 400 | wwdr_certificate, 401 | '-signer', 402 | certificate, 403 | '-inkey', 404 | key, 405 | '-outform', 406 | 'DER', 407 | '-passin', 408 | 'pass:{}'.format(password), 409 | ] 410 | process = subprocess.Popen( 411 | openssl_cmd, 412 | stderr=subprocess.PIPE, 413 | stdout=subprocess.PIPE, 414 | stdin=subprocess.PIPE, 415 | ) 416 | process.stdin.write(manifest) 417 | der, error = process.communicate() 418 | if process.returncode != 0: 419 | raise Exception(error) 420 | 421 | return der 422 | 423 | # Creates .pkpass (zip archive) 424 | def _create_zip(self, pass_json, manifest, signature, zip_file=None): 425 | zf = zipfile.ZipFile(zip_file or 'pass.pkpass', 'w') 426 | zf.writestr('signature', signature) 427 | zf.writestr('manifest.json', manifest) 428 | zf.writestr('pass.json', pass_json) 429 | for filename, filedata in self._files.items(): 430 | zf.writestr(filename, filedata) 431 | zf.close() 432 | 433 | def json_dict(self): 434 | d = { 435 | 'description': self.description, 436 | 'formatVersion': self.format_version, 437 | 'organizationName': self.organization_name, 438 | 'passTypeIdentifier': self.pass_type_identifier, 439 | 'serialNumber': self.serial_number, 440 | 'teamIdentifier': self.team_identifier, 441 | 'suppressStripShine': self.suppress_strip_shine, 442 | self.pass_information.json_name: self.pass_information.json_dict() 443 | } 444 | if self.barcode: 445 | d.update({'barcode': self.barcode.json_dict()}) 446 | if self.relevant_date: 447 | d.update({'relevantDate': self.relevant_date}) 448 | if self.background_color: 449 | d.update({'backgroundColor': self.background_color}) 450 | if self.foreground_color: 451 | d.update({'foregroundColor': self.foreground_color}) 452 | if self.label_color: 453 | d.update({'labelColor': self.label_color}) 454 | if self.logo_text: 455 | d.update({'logoText': self.logo_text}) 456 | if self.locations: 457 | d.update({'locations': self.locations}) 458 | if self.ibeacons: 459 | d.update({'beacons': self.ibeacons}) 460 | if self.user_info: 461 | d.update({'userInfo': self.user_info}) 462 | if self.associated_store_identifiers: 463 | d.update({ 464 | 'associatedStoreIdentifiers': self.associated_store_identifiers 465 | }) 466 | if self.app_launch_url: 467 | d.update({'appLaunchURL': self.app_launch_url}) 468 | if self.expiration_date: 469 | d.update({'expirationDate': self.expiration_date}) 470 | if self.voided: 471 | d.update({'voided': True}) 472 | if self.web_service_url: 473 | d.update({'webServiceURL': self.web_service_url, 474 | 'authenticationToken': self.authentication_token}) 475 | return d 476 | 477 | 478 | def pass_handler(obj): 479 | if hasattr(obj, 'json_dict'): 480 | return obj.json_dict() 481 | else: 482 | # For Decimal latitude and logitude etc. 483 | if isinstance(obj, decimal.Decimal): 484 | return str(obj) 485 | else: 486 | return obj 487 | -------------------------------------------------------------------------------- /wallets/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Silver3310/django-wallets/4e7f12e0cd31227f3622b7bc79e4407f2b7688dd/wallets/migrations/__init__.py -------------------------------------------------------------------------------- /wallets/models.py: -------------------------------------------------------------------------------- 1 | """ 2 | The models are built in accordance to the Wallet Developer Guide 3 | https://developer.apple.com/library/archive/documentation/UserExperience/Conceptual/PassKit_PG/Updating.html#//apple_ref/doc/uid/TP40012195-CH5-SW1 4 | """ 5 | from datetime import datetime 6 | 7 | from django.db import models 8 | from django.conf import settings 9 | from django.utils.translation import gettext as _ 10 | 11 | 12 | class PassAbstract(models.Model): 13 | """ 14 | The pass model for Apple Wallet 15 | """ 16 | pass_type_id = models.CharField( 17 | max_length=50, 18 | verbose_name=_('Pass Type identifier') 19 | ) 20 | serial_number = models.CharField( 21 | max_length=50, 22 | verbose_name=_('Serial number'), 23 | ) 24 | authentication_token = models.CharField( 25 | max_length=50, 26 | verbose_name=_('Authentication token') 27 | ) 28 | data = models.FileField( 29 | upload_to='passes', 30 | verbose_name=_('Data (pass file)') 31 | ) 32 | ctime = models.DateTimeField( 33 | auto_now_add=True, 34 | verbose_name=_('Created at') 35 | ) 36 | utime = models.DateTimeField( 37 | blank=True, 38 | default=datetime.now, 39 | verbose_name=_('Updated at') 40 | ) 41 | 42 | def __str__(self): 43 | return '{} ({})'.format( 44 | self.pass_type_id, 45 | self.serial_number 46 | ) 47 | 48 | class Meta: 49 | abstract = True 50 | unique_together = [['pass_type_id', 'serial_number']] 51 | ordering = ['-pk'] 52 | verbose_name = _('Apple Wallet Pass') 53 | verbose_name_plural = _('Apple Wallet Passes') 54 | 55 | 56 | class Device(models.Model): 57 | """ 58 | Device that passes are associated with 59 | """ 60 | device_library_identifier = models.CharField( 61 | max_length=50, 62 | verbose_name=_('Device identifier'), 63 | unique=True 64 | ) 65 | push_token = models.CharField( 66 | max_length=250, 67 | verbose_name=_('Push token') 68 | ) 69 | 70 | def __str__(self): 71 | return self.device_library_identifier 72 | 73 | class Meta: 74 | ordering = ['-pk'] 75 | verbose_name = _('Connected device') 76 | verbose_name_plural = _('Connected devices') 77 | 78 | 79 | class Registration(models.Model): 80 | """ 81 | A registration is a relationship between a device and a pass 82 | """ 83 | pass_object = models.ForeignKey( 84 | settings.PASS_MODEL, 85 | on_delete=models.CASCADE, 86 | verbose_name=_('Apple Wallet pass') 87 | ) 88 | device = models.ForeignKey( 89 | Device, 90 | on_delete=models.CASCADE, 91 | verbose_name=_('Associated device') 92 | ) 93 | 94 | def __str__(self): 95 | return '{} - {}'.format( 96 | self.pass_object, 97 | self.device 98 | ) 99 | 100 | class Meta: 101 | ordering = ['-pk'] 102 | unique_together = [['pass_object', 'device']] 103 | verbose_name = _('Relation: pass - device') 104 | verbose_name_plural = _('Relations: pass - device') 105 | 106 | 107 | class Log(models.Model): 108 | """ 109 | Logs that are sent by devices 110 | """ 111 | message = models.TextField( 112 | verbose_name=_('Message') 113 | ) 114 | 115 | def __str__(self): 116 | return self.message[:50] 117 | 118 | class Meta: 119 | ordering = ['-pk'] 120 | verbose_name = _('Log') 121 | verbose_name_plural = _('Logs') 122 | -------------------------------------------------------------------------------- /wallets/signals.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.db.models.signals import post_save 3 | 4 | from .models import Registration 5 | 6 | from .tasks import pass_push_apple 7 | from .tasks import pass_push_android 8 | 9 | 10 | def post_save_signal_pass_push( 11 | instance: settings.PASS_MODEL, 12 | created, 13 | **kwargs 14 | ): 15 | """After saving passes""" 16 | 17 | # Update registered devices 18 | 19 | registrations = Registration.objects.filter(pass_object=instance) 20 | if registrations.exists(): 21 | for registration in registrations: 22 | try: 23 | # android tokens are longer 24 | if len(registration.device.push_token) > 100: 25 | pass_push_android.delay( 26 | registration.device.push_token 27 | ) 28 | else: 29 | # pass_push_apple(pass_) 30 | pass_push_apple.delay( 31 | registration.device.push_token 32 | ) 33 | except Exception as e: 34 | print(e) 35 | 36 | 37 | if settings.WALLET_ENABLE_NOTIFICATIONS: 38 | post_save.connect( 39 | post_save_signal_pass_push, 40 | sender=settings.PASS_MODEL 41 | ) 42 | -------------------------------------------------------------------------------- /wallets/tasks.py: -------------------------------------------------------------------------------- 1 | import socket 2 | import ssl 3 | import json 4 | import struct 5 | import binascii 6 | import urllib 7 | 8 | from django.conf import settings 9 | 10 | try: 11 | from celery import shared_task # in case a user has celery installed 12 | except ImportError: 13 | pass 14 | 15 | 16 | @shared_task 17 | def pass_push_apple( 18 | push_token: str, 19 | ): 20 | """ 21 | Send a push notification to APNS 22 | (Apple Push Notification service) 23 | """ 24 | 25 | pay_load = {} 26 | 27 | cert = settings.WALLET_CERTIFICATE_PATH 28 | 29 | host = settings.WALLET_APN_HOST 30 | 31 | pay_load = json.dumps(pay_load, separators=(',', ':')) 32 | 33 | device_token = binascii.unhexlify(push_token) 34 | fmt = "!BH32sH{}s".format(len(pay_load)) 35 | 36 | msg = struct.pack( 37 | fmt, 38 | 0, 39 | 32, 40 | device_token, 41 | len(pay_load), 42 | bytes(pay_load, "utf-8") 43 | ) 44 | 45 | ssl_sock = ssl.wrap_socket( 46 | socket.socket( 47 | socket.AF_INET, 48 | socket.SOCK_STREAM 49 | ), 50 | certfile=cert, 51 | ) 52 | ssl_sock.connect(host) 53 | 54 | ssl_sock.write(msg) 55 | ssl_sock.close() 56 | 57 | 58 | @shared_task 59 | def pass_push_android( 60 | push_token: str 61 | ): 62 | """ 63 | Send a push notification to Android 64 | """ 65 | 66 | url = settings.WALLET_ANDROID_HOST 67 | hdr = { 68 | 'Authorization': settings.WALLET_ANDROID_API_KEY, 69 | 'Content-Type': 'application/json', 70 | } 71 | data = { 72 | "passTypeIdentifier": settings.WALLET_PASS_TYPE_ID, 73 | "pushTokens": [ 74 | push_token, 75 | ] 76 | } 77 | print(json.dumps(data)) 78 | data = json.dumps(data).encode() 79 | 80 | req = urllib.request.Request( 81 | url, 82 | headers=hdr, 83 | data=data, 84 | method='POST' 85 | ) 86 | response = urllib.request.urlopen(req) 87 | response.read() 88 | -------------------------------------------------------------------------------- /wallets/urls.py: -------------------------------------------------------------------------------- 1 | """ 2 | API urls that are built in accordance to 3 | https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html 4 | """ 5 | from django.urls import path 6 | 7 | from .views import handle_device 8 | from .views import get_serial_numbers 9 | from .views import get_latest_version 10 | from .views import log_info 11 | 12 | 13 | urlpatterns = [ 14 | path( 15 | 'v1/devices//registrations//', 16 | handle_device, # register and unregister a device 17 | name='handle_device' 18 | ), 19 | # for Android phones 20 | path( 21 | 'v1/devices//registrations_attido//', 22 | handle_device, # register and unregister a device 23 | name='handle_device' 24 | ), 25 | path( 26 | 'v1/devices//registrations/', 27 | get_serial_numbers, 28 | name='get_serial_numbers' 29 | ), 30 | path( 31 | 'v1/passes//', 32 | get_latest_version, 33 | name='get_latest_version' 34 | ), 35 | path( 36 | 'v1/log', 37 | log_info, 38 | name='log_info' 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /wallets/views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime 3 | 4 | import django.dispatch 5 | from django.conf import settings 6 | from django.views.decorators.csrf import csrf_exempt 7 | from django.views.decorators.http import condition 8 | from django.http import HttpRequest 9 | from django.http import HttpResponse 10 | from django.shortcuts import get_object_or_404 11 | from django.db.models import QuerySet 12 | from django.db.models import Max 13 | 14 | from .models import Device 15 | from .models import Registration 16 | from .models import Log 17 | 18 | 19 | FORMAT = '%Y-%m-%d %H:%M:%S' 20 | pass_registered = django.dispatch.Signal() 21 | pass_unregistered = django.dispatch.Signal() 22 | 23 | 24 | def is_authorized( 25 | request: HttpRequest, 26 | pass_: settings.PASS_MODEL, 27 | ) -> bool: 28 | """Check if a request is authorized""" 29 | 30 | client_token: str = request.META.get('HTTP_AUTHORIZATION') 31 | token_type, token = client_token.split(' ') 32 | 33 | return token_type in [ 34 | 'WalletUnionPass', # for Androids (Wallet application) 35 | 'ApplePass' # for Apple 36 | ] and token == pass_.authentication_token 37 | 38 | 39 | def get_pass( 40 | pass_type_id: str, 41 | serial_number: str 42 | ) -> settings.PASS_MODEL: 43 | """Return a pass or 404""" 44 | return get_object_or_404( 45 | settings.PASS_MODEL, 46 | pass_type_id=pass_type_id, 47 | serial_number=serial_number 48 | ) 49 | 50 | 51 | def latest_pass( 52 | request: HttpRequest, 53 | pass_type_id: str, 54 | serial_number: str 55 | ) -> datetime: 56 | return get_pass( 57 | pass_type_id, 58 | serial_number 59 | ).utime 60 | 61 | 62 | @csrf_exempt 63 | def handle_device( 64 | request: HttpRequest, 65 | device_library_id: str, 66 | pass_type_id: str, 67 | serial_number: str 68 | ): 69 | """ 70 | Handle a device request, register or unregister it 71 | """ 72 | # we already have this card 73 | pass_ = get_pass(pass_type_id, serial_number) 74 | 75 | if not is_authorized(request, pass_): 76 | return HttpResponse(status=401) 77 | 78 | # registering a device 79 | if request.method == 'POST': 80 | # if already registered 81 | try: 82 | Registration.objects.get( 83 | pass_object=pass_, 84 | device=Device.objects.get( 85 | device_library_identifier=device_library_id 86 | ) 87 | ) 88 | return HttpResponse(status=200) 89 | except (Device.DoesNotExist, Registration.DoesNotExist): 90 | body = json.loads(request.body) 91 | 92 | new_device = Device( 93 | device_library_identifier=device_library_id, 94 | push_token=body['pushToken'] 95 | ) 96 | new_device.save() 97 | new_registration = Registration( 98 | pass_object=pass_, 99 | device=new_device 100 | ) 101 | new_registration.save() 102 | 103 | pass_registered.send(sender=pass_) 104 | return HttpResponse(status=201) # Created 105 | 106 | elif request.method == 'DELETE': 107 | try: 108 | device = Device.objects.get( 109 | device_library_identifier=device_library_id 110 | ) 111 | old_registration = Registration.objects.filter( 112 | pass_object=pass_, 113 | device=device 114 | ) 115 | old_registration.delete() 116 | device.delete() 117 | pass_unregistered.send(sender=pass_) 118 | return HttpResponse(status=200) 119 | except Device.DoesNotExist: 120 | return HttpResponse(status=404) 121 | 122 | else: 123 | return HttpResponse(status=400) 124 | 125 | 126 | def get_serial_numbers( 127 | request: HttpRequest, 128 | device_library_id: str, 129 | pass_type_id: str 130 | ): 131 | """ 132 | Get the Serial Numbers for passes associated with a device 133 | """ 134 | device = get_object_or_404( 135 | Device, 136 | device_library_identifier=device_library_id 137 | ) 138 | # get all the existing passes 139 | passes = settings.PASS_MODEL.objects.filter( 140 | registration__device=device, 141 | pass_type_id=pass_type_id 142 | ) 143 | 144 | if passes.count() == 0: 145 | return HttpResponse(status=404) 146 | 147 | if 'passesUpdatedSince' in request.GET: 148 | passes: QuerySet = passes.filter(utime__gt=datetime.strptime( 149 | request.GET['passesUpdatedSince'], FORMAT 150 | )) 151 | 152 | if passes: 153 | last_updated: datetime = passes.aggregate(Max('utime'))['utime__max'] 154 | serial_numbers = [ 155 | p.serial_number for p in passes.filter( 156 | utime=last_updated 157 | ).all() 158 | ] 159 | response_data = { 160 | 'lastUpdated': last_updated.strftime(FORMAT), 161 | 'serialNumbers': serial_numbers 162 | } 163 | return HttpResponse( 164 | json.dumps(response_data), 165 | content_type="application/json" 166 | ) 167 | else: 168 | return HttpResponse(status=204) # no content 169 | 170 | 171 | @condition(last_modified_func=latest_pass) 172 | def get_latest_version( 173 | request: HttpRequest, 174 | pass_type_id: str, 175 | serial_number: str 176 | ): 177 | """ 178 | Get the latest version of pass 179 | """ 180 | pass_ = get_pass(pass_type_id, serial_number) 181 | 182 | if not is_authorized(request, pass_): 183 | return HttpResponse(status=401) 184 | 185 | response = HttpResponse( 186 | pass_.data.read(), 187 | content_type='application/vnd.apple.pkpass' 188 | ) 189 | response['Content-Disposition'] = 'attachment; filename=pass.pkpass' 190 | return response 191 | 192 | 193 | @csrf_exempt 194 | def log_info(request: HttpRequest): 195 | """ 196 | Log messages from devices 197 | """ 198 | body = json.loads(request.body) 199 | for message in body['logs']: 200 | log = Log(message=message) 201 | log.save() 202 | return HttpResponse(status=200) 203 | --------------------------------------------------------------------------------