diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index dca69d3..65ba977 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -63,6 +63,13 @@ jobs:
tags: ${{ steps.vars.outputs.TAGS }}
flavor: |
latest=${{ steps.vars.outputs.LATEST }}
+ labels: |
+ maintainer="me@zuni.kim"
+ org.opencontainers.image.authors="me@zuni.kim"
+ org.opencontainers.image.vendor="Team Overclock"
+ org.opencontainers.image.title="Lifemap backend API server"
+ org.opencontainers.image.documentation="${{ github.server_url }}/${{ github.repository }}"
+ org.opencontainers.image.ref.name="${{ github.ref_name }}"
- name: Build and push
uses: docker/build-push-action@v7
@@ -70,7 +77,10 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- target: prod
+ target: ${{ steps.vars.outputs.LATEST == 'true' && 'prod' || 'dev' }}
# pr에서는 테스트용이기 때문에 push하지 않음 (dry-run)
push: ${{ github.event_name != 'pull_request' }}
+
+ cache-from: type=gha
+ cache-to: type=gha,mode=max
diff --git a/.gitignore b/.gitignore
index dd70918..8bccb82 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,6 @@ __pycache__
# tests
.coverage
+
+# lock
+*.lock
diff --git a/Dockerfile b/Dockerfile
index d19d6ff..0b24e56 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,42 +1,61 @@
# 공통: uv + python 3.11
FROM ghcr.io/linuxserver/baseimage-alpine:3.23 AS base
+LABEL org.opencontainers.image.base.name="ghcr.io/linuxserver/baseimage-alpine:3.23"
+
ENV HOME="/config"
ENV VIRTUAL_ENV="/venv"
ENV PATH="$VIRTUAL_ENV/bin:$HOME/.local/bin:$PATH"
ENV PUID=1000
ENV PGID=1000
-WORKDIR /app
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
RUN uv python install 3.11
-COPY root/ /
+WORKDIR /defaults
-# 운영용: 공통 이미지
-FROM base AS prod-base
-ENV APP_ENV=production
-ENV UV_COMPILE_BYTECODE=1
-RUN uv venv $VIRTUAL_ENV
-# 운영용: 빌드
-FROM prod-base AS prod-build
+# 배포용: 공통 이미지
+FROM base AS deploy-base
+RUN uv venv "$VIRTUAL_ENV"
+
+# 배포용: 빌드
+FROM deploy-base AS deploy-build
+ENV CC=clang
+ENV CXX=clang++
+RUN apk add --no-cache gdal-dev clang compiler-rt
COPY requirements.txt .
-RUN uv pip install --no-cache -r requirements.txt
+RUN uv pip install --link-mode=copy --no-cache -r requirements.txt
-# 운영용: 메인
-FROM prod-base AS prod
-COPY --from=prod-build $VIRTUAL_ENV $VIRTUAL_ENV
-COPY data ./data
+# 배포용: 메인
+FROM deploy-base AS deploy
+COPY --from=deploy-build "$VIRTUAL_ENV" "$VIRTUAL_ENV"
+COPY requirements.txt .
+COPY root/ /
+COPY data/url.txt ./data/url.txt
COPY app ./app
COPY scripts ./scripts
-WORKDIR /app/scripts
+WORKDIR /app
-# 개발용 이미지
-FROM base AS dev
-ENV APP_ENV=development
-ENV PYTHONDONTWRITEBYTECODE=1
-WORKDIR /app/scripts
+# 운영용
+FROM deploy AS prod
+ENV MODE=production
+
+# 개발용: main 브랜치 push 시 자동 배포되는 이미지
+FROM deploy AS dev
+ENV MODE=development
+
+# 로컬 개발용 이미지
+FROM base AS local
+ENV MODE=local
+ENV CC=clang
+ENV CXX=clang++
+RUN apk add --no-cache gdal-dev clang compiler-rt
+COPY root/ /
+VOLUME /venv
+WORKDIR /app
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f288702
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,674 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/README.md b/README.md
index 5d0c091..a89e256 100644
--- a/README.md
+++ b/README.md
@@ -25,8 +25,7 @@
> [!NOTE]
> `requirements-dev.txt` 파일 지정 시
-> [테스트](#테스트) 및 [insert-data](#insert-data)
-> 작업에 사용되는 라이브러리까지 같이 설치됨
+> [테스트](#테스트) 시 사용되는 라이브러리까지 같이 설치됨
나열된 목록을 한 번에 설치
@@ -86,7 +85,8 @@ python3 scripts/download_data.py
```shell
python3 scripts/insert_data.py
-# docker exec -it insert-data # deprecated
+# or
+docker exec -it insert-data
```
### insert seeds
@@ -141,7 +141,8 @@ docker exec -it drop-tables [-y|--yes]
```shell
python3 scripts/drop_to_seeds.py [num_of_recs] [num_of_users]
-# docker exec -it drop-to-seeds # deprecated
+# or
+docker exec -it drop-to-seeds
```
## 테스트
diff --git a/app/config.py b/app/config.py
index 625a7df..4db0f49 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1,13 +1,14 @@
import os
+from pathlib import Path
def parse_comma_separated(key: str, default_value: str):
- value = os.getenv(key) or default_value
- items = [x.strip() for x in value.split(",") if x.strip()]
- return items
+ value = os.getenv(key) or default_value
+ items = [x.strip() for x in value.split(",") if x.strip()]
+ return items
-MODE = os.getenv("APP_ENV") or "development"
+MODE = os.getenv("MODE") or "development"
"""애플리케이션 실행 모드"""
PROD = MODE == "production"
@@ -26,3 +27,13 @@ def parse_comma_separated(key: str, default_value: str):
DEFAULT_INSERT_SEED_RECOMMENDATIONS = 30
DEFAULT_INSERT_SEED_USERS = 0
+
+
+
+DATA_DIR = Path(__file__).resolve().parent.parent / "data"
+DATA_BACKUP_DIR = DATA_DIR / ".backup"
+DATA_DOWNLOADING_DIR = DATA_DIR / ".downloading"
+DATA_DOWNLOADING_VERSION_FILE = DATA_DOWNLOADING_DIR / ".version"
+DATA_VERSION_FILE = str(DATA_DIR / "version.txt")
+DATA_VERSION_TEMP_FILE = str(DATA_DIR / ".version.tmp")
+DATA_URL_FILE = str(DATA_DIR / "url.txt")
diff --git a/app/main.py b/app/main.py
index 1a422f7..088588c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -27,7 +27,7 @@
recommendations,
)
-from .seeds import router as seeds
+from . import manage
from . import demo
@@ -135,7 +135,7 @@ def require_auth(router: APIRouter):
# Scalar 문서는 OpenAPI 목록에서 숨김
if not PROD:
app.include_router(scalar.router, prefix="/scalar", include_in_schema=False)
- app.include_router(seeds.router)
+ app.include_router(manage.router)
app.include_router(health.router)
app.include_router(public.router)
if not PROD:
diff --git a/app/manage/__init__.py b/app/manage/__init__.py
new file mode 100644
index 0000000..2378043
--- /dev/null
+++ b/app/manage/__init__.py
@@ -0,0 +1 @@
+from .router import router
diff --git a/app/manage/data/__init__.py b/app/manage/data/__init__.py
new file mode 100644
index 0000000..ecbb40d
--- /dev/null
+++ b/app/manage/data/__init__.py
@@ -0,0 +1,6 @@
+from .router import router
+
+from .insert import run as insert
+from .download import run as download
+from .download import get_current_version
+from .download import get_downloading_version
diff --git a/app/manage/data/download.py b/app/manage/data/download.py
new file mode 100644
index 0000000..dd70770
--- /dev/null
+++ b/app/manage/data/download.py
@@ -0,0 +1,125 @@
+import os
+import re
+import shutil
+import gdown
+from pathlib import Path
+
+from ...utils import read_file, write_file
+from ...config import (
+ DATA_DIR,
+ DATA_BACKUP_DIR,
+ DATA_DOWNLOADING_DIR,
+ DATA_DOWNLOADING_VERSION_FILE,
+ DATA_VERSION_FILE,
+ DATA_VERSION_TEMP_FILE,
+ DATA_URL_FILE,
+)
+
+
+
+def get_current_version():
+ return read_file(DATA_VERSION_FILE, strip=True)
+
+
+def get_downloading_version():
+ return read_file(DATA_DOWNLOADING_VERSION_FILE, strip=True)
+
+def read_url_file(filepath: str) -> dict[str, str]:
+ urls = {}
+ content = read_file(filepath)
+ if not content: return urls
+
+ for line in content.splitlines():
+ line = re.sub(r"#.*", "", line).strip()
+ if not line: continue
+
+ idx = line.find(":")
+ if idx == -1: continue
+
+ key = line[:idx].strip()
+ value = line[idx+1:].strip()
+ if value: urls[key] = value
+ return urls
+
+
+def download_file(*, silent: bool = False, **kwargs):
+ return gdown.download(
+ **kwargs,
+ quiet=silent,
+ resume=True,
+ )
+
+def download_folder(*, silent: bool = False, **kwargs):
+ return gdown.download_folder(
+ **kwargs,
+ quiet=silent,
+ resume=True,
+ )
+
+
+def move_folder(source: str, dest: str, *, ignore: list[str] = [], silent: bool = False):
+ src = Path(source)
+ dst = Path(dest)
+ dst.mkdir(parents=True, exist_ok=True)
+
+ for path in src.iterdir():
+ if path == dst or path.name in ignore:
+ continue
+ try:
+ target_path = dst / path.name
+ shutil.move(str(path), str(target_path))
+ except Exception as e:
+ if not silent: print(f"Move Failed: {e}")
+
+ if not list(src.iterdir()):
+ os.rmdir(str(src))
+
+
+
+def run(*, silent: bool = False) -> bool:
+ """
+ 데이터 디렉터리에 버전 정보를 확인하여 최신 버전이 아니면 다운로드하여 교체합니다.
+
+ Returns:
+ bool: 데이터가 업데이트되었는지 여부
+ """
+
+ P = not silent
+
+ urls = read_url_file(DATA_URL_FILE)
+
+ if P: print("현재 버전 확인 중...")
+ curr_version = read_file(DATA_VERSION_FILE, strip=True)
+ if P: print(f"현재 버전: {curr_version or 'No Version'}")
+
+ if P: print("최신 버전 확인 중...")
+ download_file(url=urls["version"], output=DATA_VERSION_TEMP_FILE, silent=True)
+ new_version: str = read_file(DATA_VERSION_TEMP_FILE, strip=True)
+ if curr_version:
+ if curr_version == new_version:
+ if P: print(f"현재 최신 버전입니다.")
+ return False
+ if P: print(f"최신 버전({new_version})을 다운로드합니다.")
+ output_folder = str(DATA_DOWNLOADING_DIR / new_version)
+
+ try:
+ write_file(str(DATA_DOWNLOADING_VERSION_FILE), new_version)
+ download_folder(url=urls["google drive"], output=output_folder, silent=silent)
+ finally:
+ os.remove(DATA_DOWNLOADING_VERSION_FILE)
+
+ if curr_version:
+ move_folder(
+ str(DATA_DIR),
+ str(DATA_BACKUP_DIR / curr_version),
+ ignore=[
+ Path(DATA_VERSION_TEMP_FILE).name,
+ DATA_BACKUP_DIR.name,
+ DATA_DOWNLOADING_DIR.name,
+ ],
+ silent=silent,
+ )
+ move_folder(output_folder, str(DATA_DIR))
+ os.remove(DATA_VERSION_TEMP_FILE)
+
+ return True
diff --git a/app/manage/data/insert.py b/app/manage/data/insert.py
new file mode 100644
index 0000000..a453bbc
--- /dev/null
+++ b/app/manage/data/insert.py
@@ -0,0 +1,1167 @@
+import re
+import numpy as np
+import pandas as pd
+import geopandas as gpd
+from typing import Generator
+from pathlib import Path
+from typing import Any
+from geopandas import GeoDataFrame
+from collections.abc import Iterable
+from sqlalchemy.orm import Session
+from sqlalchemy.sql import func
+from pyproj import Transformer
+
+from ...config import DATA_DIR
+from ...database import SessionLocal, engine
+from ...models import Base, Region, Property, Infrastructure
+from ...utils import parse_int, parse_float, run_with_progress, get_files
+
+from ...parser.csv import read_csv_file
+from ...parser.df import read_pandas, read_shp_file
+
+
+PROGRESS_PRINT = 100
+DB_BATCH_SIZE = 1000
+
+
+# global variable
+DF: dict[str, pd.DataFrame] = {}
+GDF: dict[str, GeoDataFrame] = {}
+ADDRESS_MAP = {}
+BJD_CODE_MAP = {}
+REGION_NAME_MAP = {}
+REGION_UNIQUE_MAP = {}
+PROPERTY_UNIQUE_MAP = {}
+INFRA_UNIQUE_MAP = {}
+
+
+# *_FIELD_NAME_MAP
+# - key: filename.startswith(key)
+
+PRELOAD_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
+ "rnaddrkor_": {
+ "priority": -10,
+ "file_extension": ".txt",
+ "parser": None,
+ "category": "rnaddrkor",
+ },
+ "jibun_rnaddrkor_": {
+ "priority": -10,
+ "file_extension": ".txt",
+ "parser": None,
+ "category": "jibun",
+ },
+ "AL_D164_": {
+ "priority": -10,
+ "file_extension": ".shp",
+ "category": "AL_D164",
+ },
+}
+
+REGION_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
+ "법정동코드 전체": {
+ "file_extension": ".txt",
+ "delimiter": "\t",
+ "category": "동네",
+ "type": "main",
+ "id": "법정동코드",
+ "이름": "법정동명",
+ "상태": "폐지여부",
+ },
+ "서울시 학원 교습소정보": {
+ "priority": 10,
+ "category": "동네",
+ "type": "academy",
+ "id": "학원지정번호",
+ "이름": "학원명",
+ "유형": "학원/교습소",
+ "도로명주소": "도로명주소",
+ "도로명상세주소": "도로명상세주소",
+ "운영상태": "등록상태명",
+ },
+}
+
+PROPERTY_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
+ "AL_D164_": {
+ "file_extension": ".shp",
+ "category": "건물",
+ "type": "main",
+ },
+ "서울시 공동주택 아파트 정보": {
+ "priority": 10,
+ "category": "건물",
+ "type": "apartment",
+ "id": "k-아파트코드",
+ "이름": "k-아파트명",
+ "위도": "좌표Y",
+ "경도": "좌표X",
+ "도로명주소": "kapt도로명주소",
+ "시도": "주소(시도)k-apt주소split",
+ "시군구": "주소(시군구)",
+ "읍면동": "주소(읍면동)",
+ "나머지주소": "나머지주소",
+ "도로명": "주소(도로명)",
+ "도로상세주소": "주소(도로상세주소)",
+ "동수": "k-전체동수",
+ "세대수": "k-전체세대수",
+ "주차대수": "주차대수",
+ },
+}
+
+INFRA_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
+ "전국초중등학교위치표준데이터": {
+ "category": "학교",
+ "id": "학교ID",
+ "이름": "학교명",
+ "위도": "위도",
+ "경도": "경도",
+ "지번주소": "소재지지번주소",
+ "도로명주소": "소재지도로명주소",
+ "유형": "학교급구분",
+ "운영상태": "운영상태",
+ },
+ "서울시 역사마스터 정보": {
+ "category": "역사",
+ "type": "main",
+ "id": lambda row: f"01_{row['역사명']}",
+ "이름": lambda row: row['역사명'].rstrip('역'),
+ "위도": "위도",
+ "경도": "경도",
+ },
+ "국토교통부_도시철도 전체노선": {
+ "priority": 10,
+ "category": "역사",
+ "type": "lines",
+ "id": ["권역", "역명"],
+ "이름": lambda row: row['역명'].rstrip('역'),
+ "노선": "노선명",
+ },
+ "서울시 응급실 위치 정보": {
+ "category": "병원",
+ "id": "기관ID",
+ "이름": "기관명",
+ "위도": "병원위도",
+ "경도": "병원경도",
+ "응급의료기관코드명": "응급의료기관코드명",
+ },
+ "생활_대규모점포_서울특별시": {
+ "category": "마트",
+ "id": "관리번호",
+ "이름": "사업장명",
+ "tm_x": "좌표정보(X)",
+ "tm_y": "좌표정보(Y)",
+ "영업상태명": "영업상태명",
+ "업태구분명": "업태구분명",
+ "점포구분명": "점포구분명",
+ },
+ "전국도시공원정보표준데이터": {
+ "category": "공원",
+ "id": "관리번호",
+ "이름": "공원명",
+ "위도": "위도",
+ "경도": "경도",
+ "지번주소": "소재지지번주소",
+ "도로명주소": "소재지도로명주소",
+ "면적": "공원면적",
+ },
+}
+
+
+transformer = Transformer.from_crs("epsg:5179", "epsg:4326", always_xy=True)
+
+
+def string_rcut_and_rstrip(_value: str, suffix: str) -> tuple[str, bool]:
+ value = _value.strip()
+ if value.endswith(suffix):
+ value = value[:-len(suffix)].strip()
+ return value, _value != value
+
+def all_join_or_none(*args, sep=" "):
+ if all(args):
+ return sep.join(str(arg) for arg in args)
+ return None
+
+
+def get_field_value(row: dict[str, Any], value: Any) -> str | None:
+ if isinstance(value, str):
+ return row.get(value, "").strip() or None
+
+ if isinstance(value, Iterable) and all(isinstance(v, str) for v in value):
+ return "_".join(str(row.get(key, "").strip()) for key in value) or None
+
+ if callable(value):
+ return value(row).strip() or None
+
+
+def get_coords_from_row(row: dict[str, Any], field_mapping: dict[str, Any]) -> tuple[float, float] | None:
+ """
+ 주어진 행에서 위도와 경도를 추출하여 float 형태로 반환합니다.
+
+ Args:
+ row (dict[str, Any]): CSV 파일의 한 행을 나타내는 딕셔너리
+ field_mapping (dict[str, Any]): 위도와 경도 필드 이름이 매핑된 딕셔너리
+
+ Returns:
+ tuple[float, float] | None: (위도, 경도) 튜플 또는 좌표 정보를 찾을 수 없는 경우 None
+ """
+ lat = None
+ lng = None
+ try:
+ lat = parse_float(row[field_mapping["위도"]])
+ lng = parse_float(row[field_mapping["경도"]])
+ if lat is None or lng is None: raise
+ except:
+ try:
+ tm_x = parse_float(row[field_mapping["tm_x"]])
+ tm_y = parse_float(row[field_mapping["tm_y"]])
+ lng, lat = transformer.transform(tm_x, tm_y)
+ except:
+ pass
+ if lat is None or lng is None:
+ return None
+ return lat, lng
+
+
+def normalize_sido(sido: str) -> str:
+ sido = re.sub(r"^서울(시|특별시)?($|\s+)", "서울특별시 ", sido).strip()
+ return sido
+
+
+def get_next_region_name(region_name: str) -> Generator[str, None, None]:
+ maybe_gu = region_name.split()
+ maybe_gu = maybe_gu[1] if len(maybe_gu) > 1 else None
+
+ region_name = region_name.strip()
+ yield region_name
+
+ check1 = re.sub(r"[\d -]+$", "", region_name)
+ yield check1
+
+ check2 = re.sub(r"[\d·]+", "", check1)
+ yield check2
+
+ check3 = "상일동" if maybe_gu == "강동구" and check2 == "상동" else None
+ if check3: yield check3
+
+
+def nomalize_apartment_address(name: str, address: str) -> str:
+ strip = string_rcut_and_rstrip
+ name, _ = strip(name, "아파트")
+ address, _ = strip(address, "관리소")
+ address, _ = strip(address, "관리사무소")
+ address, _ = strip(address, "아파트")
+ address, _ = strip(address, "번지")
+ address, _ = strip(address, name)
+ return address
+
+
+def clean_series_field(series, fill_val=""):
+ return series.fillna(fill_val).astype(str).str.strip()
+
+def empty_series_to_nan(series):
+ return series.astype(str).str.strip().replace("", np.nan)
+
+
+def parse_address_data(_df: pd.DataFrame, *, silent: bool = False) -> pd.DataFrame:
+ P = not silent
+ if P:
+ print()
+ print(f" - ✅ 원본 데이터 로드 완료 (총 {len(_df)}개 행)")
+ print(f" - ⏳ 1. 데이터 제거 및 정형화 작업 중...")
+
+ # 필수 데이터 결측치 제거 및 정형화
+
+ df = _df.dropna(subset=["법정동코드"])
+
+ tmp_df = df[[
+ "도로명주소관리번호",
+ "법정동코드",
+ "시도명",
+ "시군구명",
+ "법정읍면동명",
+ "법정리명",
+ "산여부",
+ "지번본번",
+ "지번부번",
+ "도로명코드",
+ "도로명",
+ "건물본번",
+ "건물부번",
+ ]].copy().apply(clean_series_field)
+
+ tmp_df["법정읍면동명"] = tmp_df["법정읍면동명"].fillna("")
+ tmp_df["법정리명"] = tmp_df["법정리명"].fillna("")
+ tmp_df["산여부"] = tmp_df["산여부"].fillna("0")
+ tmp_df["지번본번"] = tmp_df["지번본번"].fillna("0")
+ tmp_df["지번부번"] = tmp_df["지번부번"].fillna("0")
+ tmp_df["지번본번"] = tmp_df["지번본번"].str.zfill(4)
+ tmp_df["지번부번"] = tmp_df["지번부번"].str.zfill(4)
+
+ # 산여부 0과 1을 각각 1(일반 대지), 2(산)로 변환
+ tmp_df["산여부"] = np.where(tmp_df["산여부"] == "0", "1", "2")
+
+ # 시군구코드, 읍면동코드, 도로명번호 추출
+ tmp_df["시군구코드"] = tmp_df["도로명코드"].str[:5]
+ tmp_df["읍면동코드"] = tmp_df["도로명주소관리번호"].str[5:8]
+ tmp_df["도로명번호"] = tmp_df["도로명코드"].str[5:]
+
+ if P: print(f" - ⏳ 2. pnu_code 컬럼 결합 연산 및 지번/도로명 주소 매핑 중...")
+ tmp_df["pnu_code"] = tmp_df["법정동코드"] + tmp_df["산여부"] + tmp_df["지번본번"] + tmp_df["지번부번"]
+
+ tmp_df["지번주소"] = (
+ tmp_df["시도명"] + " " +
+ tmp_df["시군구명"] + " " +
+ tmp_df["법정읍면동명"] + " " +
+ tmp_df["법정리명"] + " " +
+ (
+ tmp_df["지번본번"].str.lstrip("0") +
+ "-" +
+ tmp_df["지번부번"].str.lstrip("0")
+ ).str.strip(" -")
+ ).str.replace(r"\s+", " ", regex=True).str.strip()
+
+ tmp_df["도로명주소"] = (
+ tmp_df["시도명"] + " " +
+ tmp_df["시군구명"] + " " +
+ tmp_df["도로명"] + " " +
+ (
+ tmp_df["건물본번"].str.lstrip("0") +
+ "-" +
+ tmp_df["건물부번"].str.lstrip("0")
+ ).str.strip(" -")
+ )
+
+ if P:
+ print(f" - ✅ 최종 데이터 개수: {len(tmp_df)}개")
+ print(f" - ✅ 제거된 데이터 개수: {len(df) - len(tmp_df)}")
+
+ res = df.copy()
+ res["pnu_code"] = tmp_df["pnu_code"]
+ res["시군구코드"] = tmp_df["시군구코드"]
+ res["읍면동코드"] = tmp_df["읍면동코드"]
+ res["도로명번호"] = tmp_df["도로명번호"]
+ res["도로명주소"] = tmp_df["도로명주소"]
+ res["지번주소"] = tmp_df["지번주소"]
+ return res
+
+def parse_rnaddrkor(filepath: str, *_, silent: bool = False):
+ P = not silent
+
+ rdf = read_pandas(
+ filepath,
+ names=[
+ "도로명주소관리번호",
+ "법정동코드",
+ "시도명",
+ "시군구명",
+ "법정읍면동명",
+ "법정리명",
+ "산여부",
+ "지번본번",
+ "지번부번",
+ "도로명코드",
+ "도로명",
+ "지하여부",
+ "건물본번",
+ "건물부번",
+ "행정동코드",
+ "행정동명",
+ "기초구역번호",
+ "이전도로명주소",
+ "효력발생일",
+ "공동주택구분",
+ "이동사유코드",
+ "건축물대장건물명",
+ "시군구용건물명",
+ "비고",
+ ],
+ )
+
+ rdf = parse_address_data(rdf, silent=silent)
+ if P: print(f" - ⏳ 주소 변환을 위한 사전 매핑 중...")
+ ADDRESS_MAP["rncode_to_rn"] = dict(zip(rdf["도로명코드"], rdf["도로명"]))
+ DF["rnaddrkor"] = rdf
+
+def parse_jibun_rnaddrkor(filepath: str, *_, silent: bool = False):
+ jdf = read_pandas(
+ filepath,
+ names=[
+ "도로명주소관리번호",
+ "법정동코드",
+ "시도명",
+ "시군구명",
+ "법정읍면동명",
+ "법정리명",
+ "산여부",
+ "지번본번",
+ "지번부번",
+ "도로명코드",
+ "지하여부",
+ "건물본번",
+ "건물부번",
+ "이동사유코드",
+ ],
+ )
+
+ jdf["도로명"] = jdf["도로명코드"].map(ADDRESS_MAP["rncode_to_rn"])
+ jdf = parse_address_data(jdf, silent=silent)
+ DF["jibun"] = jdf
+
+def parse_AL_D164_shp(filepath: str, *_, silent: bool = False):
+ P = not silent
+
+ gdf = read_shp_file(
+ filepath,
+ columns=[
+ "GIS건물통합식별번호",
+ "고유번호",
+ "법정동코드",
+ "법정동명",
+ "특수지구분코드",
+ "특수지구분명",
+ "지번",
+ "건물식별번호",
+ "집합건물구분코드",
+ "집합건물구분",
+ "대장종류코드",
+ "대장종류",
+ "건물명",
+ "상위건물식별번호",
+ "도로명주소코드",
+ "도로명주소읍면동리코드",
+ "도로명주소지하코드",
+ "도로명주소본번",
+ "도로명주소부번",
+ "건물동명",
+ "건물주부구분코드",
+ "건물주부구분",
+ "건물대지면적(㎡)",
+ "건물건축면적(㎡)",
+ "건물연면적(㎡)",
+ "용적율(%)",
+ "건폐율(%)",
+ "건축물구조코드",
+ "건축물구조명",
+ "건축물용도코드",
+ "건축물용도명",
+ "건물높이",
+ "지상층수",
+ "지하층수",
+ "허가일자",
+ "사용승인일자",
+ "총주차수",
+ "총주차장면적(㎡)",
+ "데이터기준일자",
+ "시군구코드",
+ "geometry",
+ ],
+ )
+
+ jdf = DF["jibun"]
+ rdf = DF["rnaddrkor"]
+ if P:
+ print()
+ print(f" - ✅ 원본 데이터 로드 완료 (총 {len(gdf)}개 행)")
+
+ # ==========================================
+ # 1. 사람이 살 수 있는 주거용 건물 추출
+ # ==========================================
+ if P: print(" - ⏳ 1. 주거용 건물(단독/공동주택 등) 필터링 중...")
+
+ gdf["건축물용도코드"] = clean_series_field(gdf["건축물용도코드"])
+ residential_mask = (
+ gdf["건축물용도코드"].str.startswith("01") |
+ gdf["건축물용도코드"].str.startswith("02") |
+ gdf["건축물용도명"].str.contains("주택|아파트|빌라|오피스텔", na=False)
+ )
+ merged_df = gdf[residential_mask].copy()
+
+ # ==========================================================
+ # 2. 주소 매핑
+ # ==========================================================
+ if P: print(f" - ⏳ 2. 주소 매핑용 룩업 사전 빌드 중...")
+
+ # 0) 주소 매핑용 사전 빌드: 관리번호와 PNU 기준으로 각각 도로명주소/지번주소 매핑할 수 있는 사전 조립
+ jdf_by_mgmt = jdf.drop_duplicates(subset=["도로명주소관리번호"])
+ rdf_by_mgmt = rdf.drop_duplicates(subset=["도로명주소관리번호"])
+
+ jdf_by_pnu = jdf.drop_duplicates(subset=["pnu_code"])
+ rdf_by_pnu = rdf.drop_duplicates(subset=["pnu_code"])
+
+ # 1) rnnum_to_rnaddr : 관리번호 -> 도로명주소 및 역매핑
+ ADDRESS_MAP.setdefault("rnnum_to_rnaddr", {})
+ ADDRESS_MAP.setdefault("rnaddr_to_rnnum", {})
+ for zips in [
+ zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["도로명주소관리번호"]),
+ zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["도로명주소관리번호"]),
+ ]:
+ for addr, num in zips:
+ ADDRESS_MAP["rnnum_to_rnaddr"].setdefault(num, addr)
+ ADDRESS_MAP["rnaddr_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num)
+
+ # 2) rnnum_to_jibun : 관리번호 -> 지번주소 및 역매핑
+ ADDRESS_MAP.setdefault("rnnum_to_jibun", {})
+ ADDRESS_MAP.setdefault("jibun_to_rnnum", {})
+ for zips in [
+ zip(jdf_by_pnu["지번주소"], jdf_by_pnu["도로명주소관리번호"]),
+ zip(rdf_by_pnu["지번주소"], rdf_by_pnu["도로명주소관리번호"]),
+ ]:
+ for addr, num in zips:
+ ADDRESS_MAP["rnnum_to_jibun"].setdefault(num, addr)
+ ADDRESS_MAP["jibun_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num)
+
+ # 3) pnu_to_rnaddr : PNU -> 도로명주소 및 역매핑
+ ADDRESS_MAP.setdefault("pnu_to_rnaddr", {})
+ ADDRESS_MAP.setdefault("rnaddr_to_pnu", {})
+ for zips in [
+ zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["pnu_code"]),
+ zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["pnu_code"]),
+ ]:
+ for addr, pnu in zips:
+ ADDRESS_MAP["pnu_to_rnaddr"].setdefault(pnu, addr)
+ ADDRESS_MAP["rnaddr_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu)
+
+ # 4) pnu_to_jibun : PNU -> 지번주소 및 역매핑
+ ADDRESS_MAP.setdefault("pnu_to_jibun", {})
+ ADDRESS_MAP.setdefault("jibun_to_pnu", {})
+ for zips in [
+ zip(jdf_by_pnu["지번주소"], jdf_by_pnu["pnu_code"]),
+ zip(rdf_by_pnu["지번주소"], rdf_by_pnu["pnu_code"]),
+ ]:
+ for addr, pnu in zips:
+ ADDRESS_MAP["pnu_to_jibun"].setdefault(pnu, addr)
+ ADDRESS_MAP["jibun_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu)
+
+ if P: print("✨ [SUCCESS] 데이터 우선순위 역전 사전(ADDRESS_MAP) 빌드 완료!")
+
+ # ==========================================
+ # 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립
+ # ==========================================
+ if P: print(" - ⏳ 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 중...")
+
+ tmp_df = merged_df[["고유번호", "도로명주소코드", "도로명주소읍면동리코드", "도로명주소지하코드", "도로명주소본번", "도로명주소부번"]].copy()
+ tmp_df = tmp_df.dropna(subset=["도로명주소코드", "도로명주소읍면동리코드"], how="all")
+
+ tmp_df["도로명주소코드"] = clean_series_field(tmp_df["도로명주소코드"])
+ tmp_df["도로명주소읍면동리코드"] = clean_series_field(tmp_df["도로명주소읍면동리코드"])
+ tmp_df["도로명주소지하코드"] = clean_series_field(tmp_df["도로명주소지하코드"], "0")
+ tmp_df["도로명주소본번"] = clean_series_field(tmp_df["도로명주소본번"], "0")
+ tmp_df["도로명주소부번"] = clean_series_field(tmp_df["도로명주소부번"], "0")
+
+ tmp_df["시군구코드"] = tmp_df["도로명주소코드"].str[:5]
+ tmp_df["읍면동코드"] = tmp_df["도로명주소읍면동리코드"].str[:3]
+ tmp_df["읍면동일련번호"] = tmp_df["도로명주소읍면동리코드"].str[3:]
+ tmp_df["도로명번호"] = tmp_df["도로명주소코드"].str[5:]
+ tmp_df["도로명주소본번"] = tmp_df["도로명주소본번"].str.zfill(5)
+ tmp_df["도로명주소부번"] = tmp_df["도로명주소부번"].str.zfill(5)
+
+ tmp_df["도로명주소관리번호"] = tmp_df["시군구코드"] + tmp_df["읍면동코드"] + tmp_df["도로명번호"] + tmp_df["도로명주소지하코드"] + tmp_df["도로명주소본번"] + tmp_df["도로명주소부번"]
+
+ # Left Join
+ tmp_subset = tmp_df[["시군구코드", "읍면동코드", "읍면동일련번호", "도로명번호", "도로명주소관리번호"]]
+ merged_df = merged_df.merge(tmp_subset, left_index=True, right_index=True, how="left")
+
+ # ==========================================
+ # 4. 국토부 GDF에 대장 주소 1차 수혈 (map 연산)
+ # ==========================================
+ if P: print(" - ⏳ 4. 국토부 GDF에 대장 주소 매핑 중...")
+ merged_df["도로명주소"] = merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_rnaddr"]).fillna(merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_rnaddr"]))
+ merged_df["지번주소"] = merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_jibun"]).fillna(merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_jibun"]))
+
+ # ==========================================
+ # 5. 대장 미매칭 구역 대상 GDF 내장 정보 결합
+ # ==========================================
+ if P: print(" - ⏳ 5. 대장 미매칭 유령 필지 대상 국토부 내장 정보 결합 및 예외 처리...")
+
+ # 1) 지번주소 Fallback: 대장 주소가 끝까지 없으면 [법정동명 + 지번] 조합으로 강제 메꿈
+ fallback_jibun = (merged_df["법정동명"] + " " + merged_df["지번"]).str.strip()
+ merged_df["지번주소"] = np.where(
+ merged_df["지번주소"].isna() | (merged_df["지번주소"] == "") | (merged_df["지번주소"] == "nan"),
+ fallback_jibun,
+ merged_df["지번주소"]
+ )
+
+ # 2) 도로명주소 Fallback: 대장 주소가 없으면 차선책으로 내장 건물명이라도 보존 (없으면 None)
+ merged_df["도로명주소"] = np.where(
+ merged_df["도로명주소"].isna() | (merged_df["도로명주소"] == "") | (merged_df["도로명주소"] == "nan"),
+ np.where(merged_df["건물명"] != "", merged_df["건물명"], None),
+ merged_df["도로명주소"]
+ )
+
+ # ==========================================
+ # 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출
+ # ==========================================
+ if P: print(" - ⏳ 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 중...")
+
+ # 추후 region 관계설정 및 좌표를 point로 변환해야 함
+ renames = {
+ "고유번호": "source_id",
+ "건축물용도명": "type",
+ "건물명": "name",
+ "지번주소": "land_lot_address",
+ "도로명주소": "road_name_address",
+ "법정동코드": "bjd_code",
+ }
+ res_df = merged_df[list(renames.keys())].rename(columns=renames)
+
+ centroid_4326 = gpd.GeoSeries(merged_df.geometry.centroid, crs=merged_df.crs).to_crs(epsg=4326)
+ res_df["lon"] = centroid_4326.x
+ res_df["lat"] = centroid_4326.y
+
+ # ====================================================
+ # 7.: 19자리 고유번호(source_id) 기준 단지화 압축 (중복 제거)
+ # ====================================================
+ if P: print(" - ⏳ 같은 아파트 단지(동별 데이터)를 하나의 대표 단지 데이터로 압축 중...")
+ agg_rules = {
+ "lon": "mean",
+ "lat": "mean",
+ "land_lot_address": "first",
+ "road_name_address": "first", # 행안부 마스터 기준으로 완벽히 통일된 주소 채택
+ "name": "first",
+ "type": "first",
+ "bjd_code": "first"
+ }
+
+ res_df = res_df.groupby("source_id", as_index=False).agg(agg_rules)
+ res_df = res_df.replace({np.nan: None})
+
+ GDF["AL_D164"] = res_df
+
+
+def parse_region(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+
+ if field_mapping["type"] == "main":
+ if not name.startswith("서울"):
+ return
+
+ status = get_field_value(row, field_mapping["상태"])
+ if status != "존재":
+ return
+
+ data = {
+ "source_id": source_id,
+ "name": name,
+ "depth": name.count(" "),
+ }
+ REGION_NAME_MAP[name] = data
+ BJD_CODE_MAP[source_id] = name
+ BJD_CODE_MAP[name] = source_id
+ return data
+ elif field_mapping["type"] == "academy":
+ status = get_field_value(row, field_mapping["운영상태"])
+ if status != "개원":
+ return
+
+ try:
+ academy_type = get_field_value(row, field_mapping["유형"])
+ if academy_type == "교습소":
+ return
+
+ addr = get_field_value(row, field_mapping["도로명주소"])
+ addr = normalize_sido(addr)
+ si, gu = addr.split()[:2]
+
+ addr_detail = get_field_value(row, field_mapping["도로명상세주소"])
+ start_idx = addr_detail.find("(")
+ if start_idx == -1: return
+
+ details = [
+ d.rstrip("층").strip()
+ for a in addr_detail[start_idx:].split("(")
+ for b in a.split(")")
+ for c in b.replace(".", ",").split(",")
+ for d in c.split(" ")
+ if d.strip()
+ ]
+ context.setdefault("academy", []).append((si, gu, details))
+ except:
+ pass
+
+def post_parse_region(context: dict[str, Any], *, silent: bool = False):
+ academy = context.get("academy", [])
+
+ for si, gu, details in academy:
+ dong = None
+ for item in details:
+ checks = get_next_region_name(item)
+ for curr in checks:
+ if not curr: continue
+ full_name = f"{si} {gu} {curr}"
+ if full_name in REGION_NAME_MAP:
+ dong = curr
+ break
+ if not dong: continue
+ for key in [si, f"{si} {gu}", f"{si} {gu} {dong}"]:
+ REGION_NAME_MAP.get(key, {}).setdefault("academy_count", 0)
+ REGION_NAME_MAP[key]["academy_count"] += 1
+
+
+def parse_apartment(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ bjd_codes = context.setdefault("bjd_codes", {})
+ if field_mapping["type"] == "main":
+ gdf = GDF["AL_D164"]
+ records = gdf.to_dict(orient="records")
+ results = context.setdefault("results", {})
+ for record in records:
+ bjd_codes[record["source_id"]] = record["bjd_code"]
+ results[record["source_id"]] = {
+ "source_id": record["source_id"],
+ "type": record["type"],
+ "name": record["name"],
+ "land_lot_address": record["land_lot_address"],
+ "road_name_address": record["road_name_address"],
+ "coordinates": (record["lat"], record["lon"]),
+ }
+ elif field_mapping["type"] == "apartment":
+ name = get_field_value(row, field_mapping["이름"])
+ apt_id = get_field_value(row, field_mapping["id"])
+
+ rn_address = get_field_value(row, field_mapping["도로명주소"])
+ sido = normalize_sido(get_field_value(row, field_mapping["시도"]) or "")
+ sigungu = get_field_value(row, field_mapping["시군구"])
+ dong = get_field_value(row, field_mapping["읍면동"])
+ jibun_suffix = get_field_value(row, field_mapping["나머지주소"])
+ road_name = get_field_value(row, field_mapping["도로명"])
+ road_suffix = get_field_value(row, field_mapping["도로상세주소"])
+ dong_count = parse_int(get_field_value(row, field_mapping["동수"]))
+ household_count = parse_int(get_field_value(row, field_mapping["세대수"]))
+ parking_count = parse_float(get_field_value(row, field_mapping["주차대수"]))
+ parking_per_household = None if parking_count is None or household_count is None else parking_count / household_count if household_count else 0
+
+ ll_address = nomalize_apartment_address(name, all_join_or_none(sido, sigungu, dong, jibun_suffix) or "") or None
+ rn_address = nomalize_apartment_address(name, rn_address or all_join_or_none(sido, sigungu, road_name, road_suffix) or "") or None
+
+ def extract_rnnum_from_kapt():
+ rn_key = rn_address.replace(" ", "") if rn_address else None
+ if rn_key in ADDRESS_MAP["rnaddr_to_rnnum"]:
+ return ADDRESS_MAP["rnaddr_to_rnnum"][rn_key][0]
+ ll_key = ll_address.replace(" ", "") if ll_address else None
+ if ll_key in ADDRESS_MAP["jibun_to_rnnum"]:
+ return ADDRESS_MAP["jibun_to_rnnum"][ll_key][0]
+ return None # 양쪽 다 실패 시 안전하게 Null 리턴
+ source_id = extract_rnnum_from_kapt()
+
+ data = {
+ "source_id": source_id or apt_id,
+ "name": name,
+ "land_lot_address": ll_address,
+ "road_name_address": rn_address,
+ "dong_count": dong_count,
+ "household_count": household_count,
+ "parking_count": parking_count,
+ "parking_per_household": parking_per_household,
+ }
+
+ if not source_id:
+ coords = get_coords_from_row(row, field_mapping)
+ if not coords:
+ return
+
+ checks = get_next_region_name(dong)
+ for curr in checks:
+ if not curr: continue
+ full_name = f"{sido} {sigungu} {curr}"
+ if full_name in BJD_CODE_MAP:
+ dong = curr
+ break
+
+ # BJD_CODE_MAP[full_name]: 항상 존재해야 함
+ bjd_codes[apt_id] = BJD_CODE_MAP[full_name]
+ return {
+ **data,
+ "coordinates": coords,
+ }
+ else:
+ if ll_address: context.setdefault("extra_data", {})[ll_address.replace(" ", "")] = data
+ if rn_address: context.setdefault("extra_data", {})[rn_address.replace(" ", "")] = data
+
+
+def parse_school(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+
+ coords = get_coords_from_row(row, field_mapping)
+ if not coords:
+ return
+
+ status = get_field_value(row, field_mapping["운영상태"])
+ if status != "운영":
+ return
+
+ address = get_field_value(row, field_mapping["도로명주소"]) or get_field_value(row, field_mapping["지번주소"])
+ if not address.startswith("서울"):
+ return
+
+ school_type = get_field_value(row, field_mapping["유형"])
+ infrastructure_type = "ELEMENTARY_SCHOOL" if school_type == "초등학교" else "MIDDLE_SCHOOL" if school_type == "중학교" else "HIGH_SCHOOL" if school_type == "고등학교" else None
+ if not infrastructure_type:
+ return
+
+ # 학군?
+ # 횡단보도 수?
+
+ return {
+ "type": infrastructure_type,
+ "source_id": source_id,
+ "name": name,
+ "coordinates": coords,
+ "details": None,
+ }
+
+
+def parse_subway_station(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+
+ if field_mapping["type"] == "main":
+ coords = get_coords_from_row(row, field_mapping)
+ if coords:
+ old_coords = context.setdefault("coords", {}).setdefault(source_id, {})
+ count = old_coords.get("count")
+ if count is None:
+ old_coords["count"] = 1
+ old_coords["lat"] = coords[0]
+ old_coords["lng"] = coords[1]
+ else:
+ count += 1
+ old_coords["count"] += count
+ old_coords["lat"] += (coords[0] - old_coords["lat"]) / count
+ old_coords["lng"] += (coords[1] - old_coords["lng"]) / count
+
+ return {
+ "type": "SUBWAY_STATION",
+ "source_id": source_id,
+ "name": name,
+ }
+ elif field_mapping["type"] == "lines":
+ result = context.get("results", {}).get(source_id)
+ if not result: return
+ line = get_field_value(row, field_mapping["노선"])
+ result.setdefault("details", {}).setdefault("lines", []).append(line)
+
+def post_parse_subway_station(context: dict[str, Any], *, silent: bool = False):
+ all_coords = context.get("coords", {})
+ for source_id in all_coords:
+ coords = all_coords[source_id]
+ if not coords: continue
+ context.get("results", {}).get(source_id, {})["coordinates"] = (coords["lat"], coords["lng"])
+
+
+def parse_large_hospital(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+
+ coords = get_coords_from_row(row, field_mapping)
+ if not coords:
+ return
+
+ code = get_field_value(row, field_mapping["응급의료기관코드명"])
+
+ details = {
+ "응급의료기관코드명": code,
+ }
+
+ infrastructure_type = "LARGE_HOSPITAL"
+ return {
+ "type": infrastructure_type,
+ "source_id": source_id,
+ "name": name,
+ "coordinates": coords,
+ "details": details,
+ }
+
+
+def parse_large_supermarket(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+ coords = get_coords_from_row(row, field_mapping)
+ if not coords:
+ return
+
+ status = get_field_value(row, field_mapping["영업상태명"])
+ if status != "영업/정상":
+ return
+
+ store_type = get_field_value(row, field_mapping["점포구분명"])
+ if store_type not in ["대규모점포", "준대규모점포"]:
+ return
+
+ biz_type = get_field_value(row, field_mapping["업태구분명"])
+
+ infrastructure_type = "LARGE_SUPERMARKET"
+ return {
+ "type": infrastructure_type,
+ "source_id": source_id,
+ "name": name,
+ "coordinates": coords,
+ "details": {
+ "업태구분명": biz_type,
+ "점포구분명": store_type,
+ },
+ }
+
+
+def parse_park(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any], *, silent: bool = False):
+ name = get_field_value(row, field_mapping["이름"])
+ source_id = get_field_value(row, field_mapping["id"])
+
+ coords = get_coords_from_row(row, field_mapping)
+ if not coords:
+ return
+
+ address = get_field_value(row, field_mapping["지번주소"]) or get_field_value(row, field_mapping["도로명주소"])
+ if not address.startswith("서울"):
+ return
+
+ try:
+ park_area = get_field_value(row, field_mapping["면적"])
+ except:
+ park_area = None
+
+ infrastructure_type = "PARK"
+ return {
+ "type": infrastructure_type,
+ "source_id": source_id,
+ "name": name,
+ "coordinates": coords,
+ "details": {
+ "면적": park_area,
+ },
+ }
+
+
+def connect_relationships(contexts: dict[str, Any], *, silent: bool = False):
+ # region self relationship
+ regions = contexts.get("동네", {}).get("instances", {})
+ for source_id in regions:
+ ins = regions[source_id]
+ name = ins.name
+ if " " in name:
+ parent_name = " ".join(name.split(" ")[:-1])
+ parent_source_id = REGION_NAME_MAP[parent_name]["source_id"]
+ parent_ins = regions[parent_source_id]
+ ins.parent = parent_ins
+
+ # property - region relationship
+ context = contexts.get("건물", {})
+ bjd_codes = context.get("bjd_codes", {})
+ properties = context.get("instances", {})
+ extra_data = context.get("extra_data", {})
+ for source_id in properties:
+ ins = properties[source_id]
+
+ bjd_code = bjd_codes[source_id]
+ ins.region = regions[bjd_code]
+
+ keys = [
+ ins.land_lot_address,
+ ins.road_name_address,
+ ]
+ extra = None
+ for key in keys:
+ key = key.replace(" ", "").strip() if key else None
+ if not key: continue
+ if key in extra_data:
+ extra = extra_data[key]
+ break
+ if not extra: continue
+ if not ins.name: ins.name = extra.get("name")
+ if not ins.land_lot_address: ins.land_lot_address = extra.get("land_lot_address")
+ if not ins.road_name_address: ins.road_name_address = extra.get("road_name_address")
+ ins.dong_count = extra.get("dong_count")
+ ins.household_count = extra.get("household_count")
+ ins.parking_count = extra.get("parking_count")
+ ins.parking_per_household = extra.get("parking_per_household")
+
+
+def _run(db: Session, target_files: list[str], *, silent: bool = False):
+ P = not silent
+
+ if P: print(f"데이터베이스에서 기존 데이터 조회 중...")
+ REGION_UNIQUE_MAP.update({x.source_id: x for x in db.query(Region).all()})
+ PROPERTY_UNIQUE_MAP.update({x.source_id: x for x in db.query(Property).all()})
+ INFRA_UNIQUE_MAP.update({(x.type.value, x.source_id): x for x in db.query(Infrastructure).all()})
+ MAP_ROUTER = {
+ Region: REGION_UNIQUE_MAP,
+ Property: PROPERTY_UNIQUE_MAP,
+ Infrastructure: INFRA_UNIQUE_MAP
+ }
+ def get_model_instance(model_cls, result: dict[str, Any]):
+ target_map = MAP_ROUTER.get(model_cls, {})
+
+ if model_cls is Region or model_cls is Property:
+ key = result["source_id"]
+ elif model_cls is Infrastructure:
+ key = (result.get("type"), result.get("source_id"))
+
+ return target_map.pop(key, None)
+
+ if P: print(f"데이터 처리 중...")
+ contexts: dict[str, Any] = {}
+ map_items = [
+ item
+ for field_map in [
+ PRELOAD_FIELD_NAME_MAP,
+ REGION_FIELD_NAME_MAP,
+ PROPERTY_FIELD_NAME_MAP,
+ INFRA_FIELD_NAME_MAP,
+ ]
+ for item in field_map.items()
+ ]
+ priorities = {}
+ for k, v in map_items:
+ p = v.get("priority", 0)
+ priorities.setdefault(p, []).append((k, v))
+
+ filepath_and_names = [(file, Path(file).stem) for file in target_files]
+ for p in sorted(priorities):
+ for key, field_mapping in priorities[p]:
+ file_extension = field_mapping.get("file_extension", ".csv")
+ filtered_files = filter(lambda x: x[1].startswith(key) and x[0].endswith(file_extension), filepath_and_names)
+ for filepath, filename in filtered_files:
+ is_iterable = False
+ if file_extension != ".shp":
+ parser = field_mapping.get("parser", "csv")
+ if parser == "csv":
+ is_iterable = True
+ header = field_mapping.get("header", True)
+ fieldnames = field_mapping.get("fieldnames")
+ delimiter = field_mapping.get("delimiter", ",")
+ rows = read_csv_file(
+ filepath,
+ header=header,
+ fieldnames=fieldnames,
+ delimiter=delimiter,
+ )
+ category = field_mapping["category"]
+ match category:
+ case "AL_D164":
+ callback = parse_AL_D164_shp
+ case "rnaddrkor":
+ callback = parse_rnaddrkor
+ case "jibun":
+ callback = parse_jibun_rnaddrkor
+ case "동네":
+ callback = parse_region
+ case "건물":
+ callback = parse_apartment
+ case "학교":
+ callback = parse_school
+ case "역사":
+ callback = parse_subway_station
+ case "병원":
+ callback = parse_large_hospital
+ case "마트":
+ callback = parse_large_supermarket
+ case "공원":
+ callback = parse_park
+ case _:
+ continue
+
+ prefix = f"[{file_extension.strip('.')}] [{filename}]"
+ context = contexts.setdefault(category, {})
+ if is_iterable:
+ def run(idx: int, row: dict[str, Any]):
+ result = callback(row, field_mapping, context, silent=silent)
+ if not result: return
+
+ source_id = get_field_value(row, field_mapping["id"])
+ context.setdefault("results", {})[source_id] = result
+ run_with_progress(rows, prefix, run, interval=PROGRESS_PRINT, silent=silent)
+ else:
+ def run(idx: int, filepath: str):
+ callback(filepath, field_mapping, context, silent=silent)
+ run_with_progress([filepath], prefix, run, interval=PROGRESS_PRINT, silent=silent)
+ if P: print(f"데이터 정리 중...")
+ for category in contexts:
+ if P: print(f" - {category}")
+ context = contexts[category]
+ match category:
+ case "동네":
+ callback = post_parse_region
+ case "역사":
+ callback = post_parse_subway_station
+ case _:
+ continue
+ callback(context, silent=silent)
+
+ if P: print(f"데이터 삽입 중...")
+ for category in contexts:
+ match category:
+ case "동네":
+ Model = Region
+ case "건물":
+ Model = Property
+ case _:
+ Model = Infrastructure
+
+ context = contexts[category]
+ results = context.get("results") or {}
+ items = list(results.values())
+ def run(idx: int, result):
+ if not result: return
+ ins = get_model_instance(Model, result)
+ if ins:
+ for key, val in result.items():
+ setattr(ins, key, val)
+ else:
+ ins = Model(**result)
+ db.add(ins)
+ ins.deleted_at = None
+ context.setdefault("instances", {})[result["source_id"]] = ins
+ run_with_progress(items, f"[{category}]", run, interval=PROGRESS_PRINT, silent=silent)
+
+ if P: print(f"테이블 관계 설정 중...")
+ connect_relationships(contexts, silent=silent)
+
+ if P: print(f"데이터 soft delete 처리 중...")
+ for model, curr_map in MAP_ROUTER.items():
+ items = list(curr_map.values())
+ def run(idx: int, ins):
+ ins.deleted_at = ins.deleted_at or func.now()
+ run_with_progress(items, f"[{model.__tablename__}]", run, interval=PROGRESS_PRINT, silent=silent)
+
+
+def run(directory_path: str | None = None, *, silent: bool = False):
+ """
+ 디렉토리 내의 CSV 파일을 읽어 데이터를 데이터베이스에 삽입하는 메인 함수입니다.
+
+ Args:
+ directory_path (str): CSV 파일이 있는 디렉토리 경로
+ Retruns:
+ None
+ """
+
+ P = not silent
+
+ if directory_path:
+ folder = Path(directory_path)
+ else:
+ folder = DATA_DIR
+
+ Base.metadata.create_all(bind=engine)
+ db = SessionLocal()
+ try:
+ if not folder.is_dir():
+ return
+
+ files = list(get_files(
+ str(folder),
+ extensions=[".csv", ".txt", ".shp"],
+ recursive=True,
+ ))
+
+ _run(db, files, silent=silent)
+ except Exception as e:
+ if P:
+ print()
+ print(f"데이터 삽입 중 오류가 발생했습니다. 롤백을 진행합니다. ({e})")
+ db.rollback()
+ raise e
+ else:
+ if P: print("데이터 삽입이 성공적으로 완료되었습니다. 커밋을 진행합니다.")
+ db.commit()
+ finally:
+ db.close()
diff --git a/app/manage/data/router.py b/app/manage/data/router.py
new file mode 100644
index 0000000..1be6fe0
--- /dev/null
+++ b/app/manage/data/router.py
@@ -0,0 +1,97 @@
+"""관리용 라우터"""
+
+from fastapi import APIRouter, BackgroundTasks, Depends, status
+from sqlalchemy import exists, select
+from sqlalchemy.orm import Session
+
+from ...database import get_db
+from ...models import Region, Property, Infrastructure
+from ..schemas import BackgroundSuccessResponse, DataDownloadStatusResponse, DataInsertStatusResponse
+
+from .insert import run as insert
+from .download import run as download
+from .download import get_current_version, get_downloading_version
+
+
+router = APIRouter(prefix="/data")
+
+
+@router.post(
+ "/sources",
+ summary="소스 데이터 파일 다운로드",
+ status_code=status.HTTP_202_ACCEPTED,
+)
+def download_data(
+ background_tasks: BackgroundTasks,
+) -> BackgroundSuccessResponse:
+ """구글 드라이브 내 소스 데이터 파일을 다운로드"""
+
+ background_tasks.add_task(
+ download,
+ silent=False,
+ )
+
+ return {
+ "requested": True,
+ }
+
+@router.get(
+ "/sources/status",
+ summary="다운로드 상태 확인",
+ status_code=status.HTTP_200_OK,
+)
+def download_data(
+) -> DataDownloadStatusResponse:
+ """구글 드라이브 내 소스 데이터 파일을 다운로드"""
+
+ curr_version = get_current_version()
+ downloading_version = get_downloading_version()
+
+ return {
+ "curr_version": curr_version,
+ "downloading_version": downloading_version,
+ }
+
+
+@router.patch(
+ "",
+ summary="데이터 삽입",
+ status_code=status.HTTP_202_ACCEPTED,
+)
+def insert_data(
+ background_tasks: BackgroundTasks,
+) -> BackgroundSuccessResponse:
+ """다운로드된 소스 데이터 파일을 읽어 데이터베이스에 삽입"""
+
+ background_tasks.add_task(
+ insert,
+ silent=False,
+ )
+
+ return {
+ "requested": True,
+ }
+
+@router.get(
+ "/status",
+ summary="데이터 삽입 상태 확인",
+ status_code=status.HTTP_200_OK,
+)
+def check_data_status(
+ db: Session = Depends(get_db),
+) -> DataInsertStatusResponse:
+ """데이터 삽입 상태 확인"""
+
+ result = db.execute(
+ select(
+ exists().where(Region.id),
+ exists().where(Property.id),
+ exists().where(Infrastructure.id),
+ ),
+ ).tuples().first()
+
+ return {
+ "has_region": result[0],
+ "has_property": result[1],
+ "has_infrastructure": result[2],
+ }
diff --git a/app/manage/router.py b/app/manage/router.py
new file mode 100644
index 0000000..1489cce
--- /dev/null
+++ b/app/manage/router.py
@@ -0,0 +1,13 @@
+"""관리용 라우터"""
+
+from fastapi import APIRouter
+
+from . import tables, data, seeds
+
+
+router = APIRouter(prefix="/manage", tags=["manage"])
+
+
+router.include_router(tables.router)
+router.include_router(data.router)
+router.include_router(seeds.router)
diff --git a/app/manage/schemas.py b/app/manage/schemas.py
new file mode 100644
index 0000000..ebd7c45
--- /dev/null
+++ b/app/manage/schemas.py
@@ -0,0 +1,40 @@
+from typing import Literal
+from pydantic import BaseModel, Field
+
+
+class ImmediateResponse(BaseModel):
+ """즉시 실행 응답"""
+
+ success: bool
+
+class BackgroundSuccessResponse(BaseModel):
+ """백그라운드 실행 성공 응답"""
+
+ requested: Literal[True]
+
+
+class DataDownloadStatusResponse(BaseModel):
+ """데이터 상태 조회 응답"""
+
+ curr_version: str | None
+ downloading_version: str | None
+
+class DataInsertStatusResponse(BaseModel):
+ """데이터 상태 조회 응답"""
+
+ has_region: bool
+ has_property: bool
+ has_infrastructure: bool
+
+
+class GenerateSeedsRequest(BaseModel):
+ """시드 데이터 생성 요청"""
+
+ users: int = Field(0, description="생성할 사용자 수")
+ recommendations: int = Field(0, description="생성할 추천 수")
+
+class SeedsStatusResponse(BaseModel):
+ """시드 데이터 개수 조회 응답"""
+
+ total_users: int
+ total_recommendations: int
diff --git a/app/manage/seeds/__init__.py b/app/manage/seeds/__init__.py
new file mode 100644
index 0000000..f139b37
--- /dev/null
+++ b/app/manage/seeds/__init__.py
@@ -0,0 +1,4 @@
+from .router import router
+
+from .insert import run as insert
+from .drop import run as drop
diff --git a/app/seeds/drop.py b/app/manage/seeds/drop.py
similarity index 71%
rename from app/seeds/drop.py
rename to app/manage/seeds/drop.py
index e2afca4..f50ed0b 100644
--- a/app/seeds/drop.py
+++ b/app/manage/seeds/drop.py
@@ -1,6 +1,6 @@
-from ..database import SessionLocal
-from ..models import User, Recommendation
-from ..config import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX
+from ...database import SessionLocal
+from ...models import User, Recommendation
+from ...config import SEED_TASK_ID_PREFIX, SEED_USERNAME_PREFIX
def run():
diff --git a/app/seeds/insert.py b/app/manage/seeds/insert.py
similarity index 96%
rename from app/seeds/insert.py
rename to app/manage/seeds/insert.py
index c9760e0..d3fd8f3 100644
--- a/app/seeds/insert.py
+++ b/app/manage/seeds/insert.py
@@ -2,13 +2,13 @@
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
-from ..core.enums import INFRASTRUCTURE_TYPE_VALUES
-from ..redis import redis
-from ..database import SessionLocal
-from ..demo import create_guest_user
-from ..crud.service import get_regions
-from ..utils import run_with_progress
-from ..models import (
+from ...core.enums import INFRASTRUCTURE_TYPE_VALUES
+from ...redis import redis
+from ...database import SessionLocal
+from ...demo import create_guest_user
+from ...crud.service import get_regions
+from ...utils import run_with_progress
+from ...models import (
User,
SearchLog,
Recommendation,
@@ -17,7 +17,7 @@
PropertyInfrastructure,
)
-from ..config import (
+from ...config import (
SEED_TASK_ID_PREFIX,
SEED_USERNAME_PREFIX,
DEFAULT_INSERT_SEED_RECOMMENDATIONS,
diff --git a/app/manage/seeds/router.py b/app/manage/seeds/router.py
new file mode 100644
index 0000000..7e36761
--- /dev/null
+++ b/app/manage/seeds/router.py
@@ -0,0 +1,86 @@
+"""시드 데이터 관련 API 라우터"""
+
+from fastapi import APIRouter, BackgroundTasks, Depends, status
+from sqlalchemy import union_all, select, func
+from sqlalchemy.orm import Session
+
+from ...config import SEED_USERNAME_PREFIX, SEED_TASK_ID_PREFIX
+from ...database import get_db
+from ...models import User, Recommendation
+
+from ..schemas import ImmediateResponse, BackgroundSuccessResponse, GenerateSeedsRequest, SeedsStatusResponse
+from .insert import run as insert_seeds
+from .drop import run as drop_seeds
+
+
+
+router = APIRouter(prefix="/seeds")
+
+@router.post(
+ "",
+ summary="시드 데이터 생성",
+ status_code=status.HTTP_202_ACCEPTED,
+)
+def generate_seeds(
+ body: GenerateSeedsRequest,
+ background_tasks: BackgroundTasks,
+) -> BackgroundSuccessResponse:
+ """랜덤 시드 데이터 생성"""
+
+ background_tasks.add_task(
+ insert_seeds,
+ num_recommendations=body.recommendations,
+ num_users=body.users,
+ silent=True,
+ )
+
+ return {
+ "requested": True,
+ }
+
+@router.delete(
+ "",
+ summary="시드 데이터 삭제",
+ status_code=status.HTTP_200_OK,
+)
+def delete_seeds(
+) -> ImmediateResponse:
+ """
+ 랜덤 시드 데이터 삭제.
+ 단, property-infra 사이 점수 등 데이터는 삭제되지 않음.
+ """
+
+ try:
+ drop_seeds()
+ success = True
+ except:
+ success = False
+
+ return {
+ "success": success,
+ }
+
+@router.get(
+ "/status",
+ summary="시드 데이터 개수 조회",
+ status_code=status.HTTP_200_OK,
+)
+def check_seed_status(
+ db: Session = Depends(get_db),
+) -> SeedsStatusResponse:
+ """
+ 사용자 및 추천 시드 데이터 개수 조회
+ (게스트 유저는 미포함, 게스트와 연결된 시드 데이터는 포함됨)
+ """
+
+ total_users, total_recs = db.execute(
+ union_all(
+ select(func.count(User.id)).filter(User.email.startswith(SEED_USERNAME_PREFIX)),
+ select(func.count(Recommendation.id)).filter(Recommendation.task_id.startswith(SEED_TASK_ID_PREFIX)),
+ )
+ ).scalars().all()
+
+ return {
+ "total_users": total_users,
+ "total_recommendations": total_recs,
+ }
diff --git a/app/manage/tables/__init__.py b/app/manage/tables/__init__.py
new file mode 100644
index 0000000..ec13674
--- /dev/null
+++ b/app/manage/tables/__init__.py
@@ -0,0 +1,4 @@
+from .router import router
+
+from .create import run as create
+from .drop import run as drop
diff --git a/app/manage/tables/create.py b/app/manage/tables/create.py
new file mode 100644
index 0000000..3cfbabd
--- /dev/null
+++ b/app/manage/tables/create.py
@@ -0,0 +1,9 @@
+from app.database import engine
+from app.models import Base
+
+
+def run():
+ try:
+ Base.metadata.create_all(bind=engine)
+ except Exception as e:
+ raise e
diff --git a/app/manage/tables/drop.py b/app/manage/tables/drop.py
new file mode 100644
index 0000000..c0fc00c
--- /dev/null
+++ b/app/manage/tables/drop.py
@@ -0,0 +1,9 @@
+from app.database import engine
+from app.models import Base
+
+
+def run():
+ try:
+ Base.metadata.drop_all(bind=engine)
+ except Exception as e:
+ raise e
diff --git a/app/manage/tables/router.py b/app/manage/tables/router.py
new file mode 100644
index 0000000..eab40dc
--- /dev/null
+++ b/app/manage/tables/router.py
@@ -0,0 +1,50 @@
+"""관리용 라우터"""
+
+from fastapi import APIRouter, status
+
+from ..schemas import ImmediateResponse
+
+from .create import run as create
+from .drop import run as drop
+
+
+router = APIRouter(prefix="/tables")
+
+
+@router.post(
+ "",
+ summary="테이블 생성",
+ status_code=status.HTTP_201_CREATED,
+)
+def create_table(
+) -> ImmediateResponse:
+ """모든 테이블 생성"""
+
+ try:
+ create()
+ success = True
+ except:
+ success = False
+
+ return {
+ "success": success,
+ }
+
+@router.delete(
+ "",
+ summary="테이블 삭제",
+ status_code=status.HTTP_200_OK,
+)
+def drop_table(
+) -> ImmediateResponse:
+ """모든 테이블 삭제"""
+
+ try:
+ drop()
+ success = True
+ except:
+ success = False
+
+ return {
+ "success": success,
+ }
diff --git a/app/parser/__init__.py b/app/parser/__init__.py
new file mode 100644
index 0000000..f22cc36
--- /dev/null
+++ b/app/parser/__init__.py
@@ -0,0 +1,2 @@
+from . import csv
+from . import df
diff --git a/scripts/csv_parser.py b/app/parser/csv.py
similarity index 100%
rename from scripts/csv_parser.py
rename to app/parser/csv.py
diff --git a/scripts/df_parser.py b/app/parser/df.py
similarity index 100%
rename from scripts/df_parser.py
rename to app/parser/df.py
diff --git a/app/redis.py b/app/redis.py
index d40f043..d857871 100644
--- a/app/redis.py
+++ b/app/redis.py
@@ -13,14 +13,14 @@
redis_db = os.getenv("REDIS_DB") or "0"
redis = Redis(
- host=redis_host,
- port=redis_port,
- username=quote_plus(redis_user) if redis_user else None,
- password=quote_plus(redis_pass) if redis_pass else None,
- db=redis_db,
- encoding="utf-8",
- decode_responses=True,
+ host=redis_host,
+ port=redis_port,
+ username=quote_plus(redis_user) if redis_user else None,
+ password=quote_plus(redis_pass) if redis_pass else None,
+ db=redis_db,
+ encoding="utf-8",
+ decode_responses=True,
)
def get_redis():
- yield redis
+ yield redis
diff --git a/app/seeds/router.py b/app/seeds/router.py
deleted file mode 100644
index 8290cb9..0000000
--- a/app/seeds/router.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""개발용"""
-
-from typing import Literal
-from pydantic import BaseModel, Field
-from fastapi import APIRouter, BackgroundTasks, status
-
-from .insert import run as insert_seeds
-from .drop import run as drop_seeds
-
-
-class GenerateSeedsRequest(BaseModel):
- """시드 데이터 생성 요청"""
-
- users: int = Field(0, description="생성할 사용자 수")
- recommendations: int = Field(0, description="생성할 추천 수")
-
-class SeedsResponse(BaseModel):
- """시드 데이터 생성/삭제 응답"""
-
- requested: Literal[True]
-
-
-router = APIRouter(prefix="/seeds", tags=["seeds"])
-
-@router.post(
- "",
- summary="시드 데이터 생성",
- status_code=status.HTTP_202_ACCEPTED,
-)
-def generate_seeds(
- body: GenerateSeedsRequest,
- background_tasks: BackgroundTasks,
-) -> SeedsResponse:
- """랜덤 시드 데이터 생성"""
-
- # 백그라운드로 생성 및 즉시 응답
- background_tasks.add_task(
- insert_seeds,
- num_recommendations=body.recommendations,
- num_users=body.users
- )
-
- return {
- "requested": True,
- }
-
-@router.delete(
- "",
- summary="시드 데이터 삭제",
- status_code=status.HTTP_202_ACCEPTED,
-)
-def delete_seeds(
- background_tasks: BackgroundTasks,
-) -> SeedsResponse:
- """
- 랜덤 시드 데이터 삭제.
- 단, property-infra 사이 점수 등 데이터는 삭제되지 않음.
- """
-
- # 백그라운드로 생성 및 즉시 응답
- background_tasks.add_task(
- drop_seeds,
- )
-
- return {
- "requested": True,
- }
diff --git a/app/utils.py b/app/utils.py
index c4e6161..7110844 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -107,7 +107,9 @@ def progress(
prefix = msg
status = current if total is None else f"{current}/{total}"
suffix = unit if total is None or unit_always else ""
- suffix += f" {current/total*100:.2f}%" if percentage and total is not None else ""
+ if percentage and total is not None:
+ percentage_value = current / total * 100 if total else 100
+ suffix += f" ({percentage_value:.2f}%)"
print(f"\r {prefix} {status} {suffix}\x1b[K", end=end, flush=True)
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 49cf2a6..8e890d8 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -2,5 +2,3 @@
pytest
pytest-cov
-geopandas
-gdown
diff --git a/requirements.txt b/requirements.txt
index 55a8fa7..8e8663c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -8,3 +8,5 @@ argon2-cffi
python-dotenv
cuid2
redis
+geopandas
+gdown
diff --git a/root/etc/s6-overlay/s6-rc.d/app-run/run b/root/etc/s6-overlay/s6-rc.d/app-run/run
index 8abc977..fcfe7dc 100755
--- a/root/etc/s6-overlay/s6-rc.d/app-run/run
+++ b/root/etc/s6-overlay/s6-rc.d/app-run/run
@@ -1,10 +1,12 @@
#!/usr/bin/with-contenv bash
+source "/app/scripts/functions.sh"
+
cd /app
-echo "**** Starting app ****"
+SUCCESS "**** Starting app ****"
-if [ "$NODE_ENV" != "production" ]; then
+if [ "$MODE" != "production" ]; then
exec s6-setuidgid abc fastapi dev app --host 0.0.0.0
else
exec s6-setuidgid abc fastapi run app --host 0.0.0.0
diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/dependencies.d/init-config b/root/etc/s6-overlay/s6-rc.d/deps-install/dependencies.d/file-delivery
similarity index 100%
rename from root/etc/s6-overlay/s6-rc.d/init-bin/dependencies.d/init-config
rename to root/etc/s6-overlay/s6-rc.d/deps-install/dependencies.d/file-delivery
diff --git a/root/etc/s6-overlay/s6-rc.d/deps-install/run b/root/etc/s6-overlay/s6-rc.d/deps-install/run
index 3d1a511..d9f7833 100755
--- a/root/etc/s6-overlay/s6-rc.d/deps-install/run
+++ b/root/etc/s6-overlay/s6-rc.d/deps-install/run
@@ -1,17 +1,37 @@
#!/usr/bin/with-contenv bash
+source "/app/scripts/functions.sh"
+
cd /app
-if [ "$APP_ENV" != "production" ]; then
- echo "**** Installing dependencies... ****"
+if [ "$MODE" == "production" ]; then
+ exit 0
+fi
+
- if [ ! -f "requirements.txt" ]; then
- echo "Error: requirements.txt not found" >&2
- exit 1
- fi
- if [ ! -f "$VIRTUAL_ENV/bin/activate" ]; then
- uv venv "$VIRTUAL_ENV"
- fi
- uv pip install --no-cache -r requirements.txt
+if [ -f ".requirements.lock" ] \
+&& [ -f "$VIRTUAL_ENV/.requirements.lock" ] \
+&& diff .requirements.lock "$VIRTUAL_ENV/.requirements.lock" > /dev/null \
+&& sha256sum -c .requirements.lock --quiet 2> /dev/null \
+; then
+ INFO "Skipping dependency installation, requirements.txt has not changed."
+ exit 0
fi
+
+
+
+SUCCESS "**** Installing dependencies... ****"
+
+if [ ! -f "requirements.txt" ]; then
+ ERROR "Error: requirements.txt not found" >&2
+ exit 1
+fi
+
+if [ ! -f "$VIRTUAL_ENV/bin/activate" ]; then
+ uv venv "$VIRTUAL_ENV"
+fi
+
+uv pip install --link-mode=copy --no-cache -r requirements.txt
+sha256sum requirements.txt > .requirements.lock
+cp .requirements.lock "$VIRTUAL_ENV/.requirements.lock"
diff --git a/root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/init-config b/root/etc/s6-overlay/s6-rc.d/file-delivery/dependencies.d/init-config
similarity index 100%
rename from root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/init-config
rename to root/etc/s6-overlay/s6-rc.d/file-delivery/dependencies.d/init-config
diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/run b/root/etc/s6-overlay/s6-rc.d/file-delivery/run
new file mode 100755
index 0000000..7daad86
--- /dev/null
+++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/run
@@ -0,0 +1,7 @@
+#!/usr/bin/with-contenv bash
+
+if [ "$MODE" == "local" ] || [ -d "/app/app" ]; then
+ exit 0
+fi
+
+mv /defaults/* /app
diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/type b/root/etc/s6-overlay/s6-rc.d/file-delivery/type
similarity index 100%
rename from root/etc/s6-overlay/s6-rc.d/init-bin/type
rename to root/etc/s6-overlay/s6-rc.d/file-delivery/type
diff --git a/root/etc/s6-overlay/s6-rc.d/file-delivery/up b/root/etc/s6-overlay/s6-rc.d/file-delivery/up
new file mode 100755
index 0000000..a0914dd
--- /dev/null
+++ b/root/etc/s6-overlay/s6-rc.d/file-delivery/up
@@ -0,0 +1 @@
+/etc/s6-overlay/s6-rc.d/file-delivery/run
diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/up b/root/etc/s6-overlay/s6-rc.d/init-bin/up
deleted file mode 100755
index 3b0ba68..0000000
--- a/root/etc/s6-overlay/s6-rc.d/init-bin/up
+++ /dev/null
@@ -1 +0,0 @@
-/etc/s6-overlay/s6-rc.d/init-bin/run
diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/init-bin b/root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/file-delivery
similarity index 100%
rename from root/etc/s6-overlay/s6-rc.d/user/contents.d/init-bin
rename to root/etc/s6-overlay/s6-rc.d/init-chown/dependencies.d/file-delivery
diff --git a/root/etc/s6-overlay/s6-rc.d/init-chown/run b/root/etc/s6-overlay/s6-rc.d/init-chown/run
index c1a665b..5f515f8 100755
--- a/root/etc/s6-overlay/s6-rc.d/init-chown/run
+++ b/root/etc/s6-overlay/s6-rc.d/init-chown/run
@@ -1,3 +1,5 @@
#!/usr/bin/with-contenv bash
-lsiown abc:abc "$HOME"/.cache
+if [ "$MODE" != "local" ]; then
+ chown abc:abc -R /app
+fi
diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/dependencies.d/file-delivery b/root/etc/s6-overlay/s6-rc.d/register-bin/dependencies.d/file-delivery
new file mode 100644
index 0000000..e69de29
diff --git a/root/etc/s6-overlay/s6-rc.d/init-bin/run b/root/etc/s6-overlay/s6-rc.d/register-bin/run
similarity index 100%
rename from root/etc/s6-overlay/s6-rc.d/init-bin/run
rename to root/etc/s6-overlay/s6-rc.d/register-bin/run
diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/type b/root/etc/s6-overlay/s6-rc.d/register-bin/type
new file mode 100644
index 0000000..bdd22a1
--- /dev/null
+++ b/root/etc/s6-overlay/s6-rc.d/register-bin/type
@@ -0,0 +1 @@
+oneshot
diff --git a/root/etc/s6-overlay/s6-rc.d/register-bin/up b/root/etc/s6-overlay/s6-rc.d/register-bin/up
new file mode 100755
index 0000000..1bbffe7
--- /dev/null
+++ b/root/etc/s6-overlay/s6-rc.d/register-bin/up
@@ -0,0 +1 @@
+/etc/s6-overlay/s6-rc.d/register-bin/run
diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/file-delivery b/root/etc/s6-overlay/s6-rc.d/user/contents.d/file-delivery
new file mode 100644
index 0000000..e69de29
diff --git a/root/etc/s6-overlay/s6-rc.d/user/contents.d/register-bin b/root/etc/s6-overlay/s6-rc.d/user/contents.d/register-bin
new file mode 100644
index 0000000..e69de29
diff --git a/scripts/download_data.py b/scripts/download_data.py
index 3cce71a..25cf7fd 100644
--- a/scripts/download_data.py
+++ b/scripts/download_data.py
@@ -9,19 +9,6 @@
# backend 디렉터리를 Python 경로에 추가
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
-from app.utils import read_file, write_file
-
-
-TIMESTAMP = round(time())
-SCRATCH_TIMESTAMP_FILENAME = ".timestamp"
-
-DATA_DIR = Path(__file__).resolve().parent.parent / "data"
-BACKUP_DIR = DATA_DIR / ".backup"
-DOWNLOADING_DIR = DATA_DIR / ".downloading"
-VERSION_FILE = str(DATA_DIR / "version.txt")
-VERSION_TEMP_FILE = str(DATA_DIR / ".version.tmp")
-URL_FILE = str(DATA_DIR / "url.txt")
-
def usage():
print("사용법: python download_data.py")
@@ -29,115 +16,6 @@ def usage():
print(" - 다운로드된 파일은 data 폴더에 위치합니다.")
-def make_scratch_folder():
- file = DOWNLOADING_DIR / "scratch" / SCRATCH_TIMESTAMP_FILENAME
- if file.exists():
- ts = int(read_file(str(file), strip=True))
- if TIMESTAMP - ts < 60 * 60 * 24:
- return file.parent
-
- shutil.rmtree(str(file.parent), ignore_errors=True)
- file.parent.mkdir(parents=True, exist_ok=True)
- write_file(str(file), str(TIMESTAMP))
- return file.parent
-
-
-def read_url_file(filepath: str) -> dict[str, str]:
- urls = {}
- content = read_file(filepath)
- if not content: return urls
-
- for line in content.splitlines():
- line = re.sub(r"#.*", "", line).strip()
- if not line: continue
-
- idx = line.find(":")
- if idx == -1: continue
-
- key = line[:idx].strip()
- value = line[idx+1:].strip()
- if value: urls[key] = value
- return urls
-
-
-def download_file(*, silent: bool = False, **kwargs):
- return gdown.download(
- **kwargs,
- quiet=silent,
- resume=True,
- )
-
-def download_folder(*, silent: bool = False, **kwargs):
- return gdown.download_folder(
- **kwargs,
- quiet=silent,
- resume=True,
- )
-
-
-def move_folder(source: str, dest: str, *, ignore: list[str] = [], silent: bool = False):
- src = Path(source)
- dst = Path(dest)
- dst.mkdir(parents=True, exist_ok=True)
-
- for path in src.iterdir():
- if path == dst or path.name in ignore:
- continue
- try:
- target_path = dst / path.name
- shutil.move(str(path), str(target_path))
- except Exception as e:
- if not silent: print(f"Move Failed: {e}")
-
- if not list(src.iterdir()):
- os.rmdir(str(src))
-
-
-
-def run(*, silent: bool = False):
- P = not silent
-
- urls = read_url_file(URL_FILE)
-
- if P: print("현재 버전 확인 중...")
- curr_version = read_file(VERSION_FILE, strip=True)
- if P: print(f"현재 버전: {curr_version or 'No Version'}")
-
- if not curr_version:
- if P: print("버전 파일이 없습니다. 데이터를 다운로드합니다.")
- output_folder = str(make_scratch_folder())
- else:
- if P: print("최신 버전 확인 중...")
- download_file(url=urls["version"], output=VERSION_TEMP_FILE, silent=True)
-
- new_version: str = read_file(VERSION_TEMP_FILE, strip=True)
- if curr_version == new_version:
- if P: print(f"현재 최신 버전입니다.")
- return False
- if P: print(f"최신 버전({new_version})을 다운로드합니다.")
- output_folder = str(DOWNLOADING_DIR / new_version)
-
- download_folder(url=urls["google drive"], output=output_folder, silent=silent)
-
- if curr_version:
- move_folder(
- str(DATA_DIR),
- str(BACKUP_DIR / curr_version),
- ignore=[
- Path(VERSION_TEMP_FILE).name,
- BACKUP_DIR.name,
- DOWNLOADING_DIR.name,
- ],
- silent=silent,
- )
- move_folder(output_folder, str(DATA_DIR))
- if curr_version:
- os.remove(VERSION_TEMP_FILE)
- else:
- os.remove(str(DATA_DIR / SCRATCH_TIMESTAMP_FILENAME))
-
- return True
-
if __name__ == "__main__":
if "-h" in sys.argv or "--help" in sys.argv:
@@ -147,4 +25,5 @@ def run(*, silent: bool = False):
usage()
sys.exit(1)
- run()
+ from app.manage import data
+ data.download()
diff --git a/scripts/drop_seeds.py b/scripts/drop_seeds.py
index c76a928..ba59d4e 100755
--- a/scripts/drop_seeds.py
+++ b/scripts/drop_seeds.py
@@ -8,10 +8,21 @@
def usage():
- print("사용법: python drop-seeds.py [-y|--yes]")
+ print("사용법: python drop_seeds.py [-y|--yes]")
print(" - 모든 시드 데이터를 삭제합니다.")
+def main():
+ try:
+ from app.manage import seeds
+ seeds.drop()
+ except Exception as e:
+ print("시드 데이터 삭제 중 오류가 발생했습니다.")
+ raise e
+ else:
+ print("모든 시드 데이터가 삭제되었습니다.")
+
+
if __name__ == "__main__":
if "-h" in sys.argv or "--help" in sys.argv:
usage()
@@ -26,11 +37,4 @@ def usage():
print("작업이 취소되었습니다.")
sys.exit(0)
- from app.seeds.drop import run as drop_seeds
- try:
- drop_seeds()
- except Exception as e:
- print("시드 데이터 삭제 중 오류가 발생했습니다.")
- raise e
- else:
- print("모든 시드 데이터가 삭제되었습니다.")
+ main()
diff --git a/scripts/drop_tables.py b/scripts/drop_tables.py
index 63e4a14..082d5c4 100755
--- a/scripts/drop_tables.py
+++ b/scripts/drop_tables.py
@@ -6,24 +6,19 @@
def usage():
- print("사용법: python drop-tables.py [-y|--yes]")
+ print("사용법: python drop_tables.py [-y|--yes]")
print(" - 모든 데이터베이스 테이블을 삭제합니다.")
-def main(*, force: bool = False):
- if force is True:
- drop = True
- else:
- print("⚠️ 경고: 모든 데이터베이스 테이블이 삭제됩니다.")
- confirm = input("정말로 진행하시겠습니까? (y/N): ").lower()
- drop = confirm in ("y", "yes")
- if drop:
- from app.database import engine
- from app.models import Base
- Base.metadata.drop_all(bind=engine)
- print("모든 테이블이 삭제되었습니다.")
+def main():
+ try:
+ from app.manage import tables
+ tables.drop()
+ except Exception as e:
+ print("데이터베이스 테이블 삭제 중 오류가 발생했습니다.")
+ raise e
else:
- print("작업이 취소되었습니다.")
+ print("모든 데이터베이스 테이블이 삭제되었습니다.")
if __name__ == "__main__":
@@ -33,7 +28,11 @@ def main(*, force: bool = False):
elif len(sys.argv) > 2:
usage()
sys.exit(1)
- elif len(sys.argv) == 2 and sys.argv[1] in ("-y", "--yes"):
- main(force=True)
- else:
- main()
+ elif len(sys.argv) != 2 or sys.argv[1] not in ("-y", "--yes"):
+ print("⚠️ 경고: 모든 데이터베이스 테이블이 삭제됩니다.")
+ confirm = input("정말로 진행하시겠습니까? (y/N): ").lower()
+ if confirm not in ("y", "yes"):
+ print("작업이 취소되었습니다.")
+ sys.exit(0)
+
+ main()
diff --git a/scripts/drop_to_seeds.py b/scripts/drop_to_seeds.py
old mode 100644
new mode 100755
index e185a7d..7ad28b8
--- a/scripts/drop_to_seeds.py
+++ b/scripts/drop_to_seeds.py
@@ -1,3 +1,5 @@
+#!/usr/bin/with-contenv python3
+
import os
import sys
import time
@@ -35,11 +37,11 @@ def usage():
sys.exit(1)
from scripts import drop_tables, insert_data
- from app.seeds.insert import run as insert_seeds
+ from app.manage import seeds
try:
drop_tables.main(force=True)
insert_data.main()
- insert_seeds(*args)
+ seeds.insert(*args)
except Exception as e:
print("작업 중 오류가 발생했습니다.")
raise e
diff --git a/scripts/insert_data.py b/scripts/insert_data.py
old mode 100644
new mode 100755
index bcf4562..aacbd25
--- a/scripts/insert_data.py
+++ b/scripts/insert_data.py
@@ -1,1173 +1,24 @@
-import re
+#!/usr/bin/with-contenv python3
+
import os
import sys
-import numpy as np
-import pandas as pd
-import geopandas as gpd
-from typing import Generator
-from pathlib import Path
-from typing import Any
-from geopandas import GeoDataFrame
-from collections.abc import Iterable
-from sqlalchemy.orm import Session
-from sqlalchemy.sql import func
-from pyproj import Transformer
# backend 디렉터리를 Python 경로에 추가
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
-from app.database import SessionLocal, engine
-from app.models import Base, Region, Property, Infrastructure
-from app.utils import parse_int, parse_float, run_with_progress, get_files
-
-from scripts.csv_parser import read_csv_file
-from scripts.df_parser import read_pandas, read_shp_file
-
-
-PROGRESS_PRINT = 100
-DB_BATCH_SIZE = 1000
-
-
-# global variable
-DF: dict[str, pd.DataFrame] = {}
-GDF: dict[str, GeoDataFrame] = {}
-ADDRESS_MAP = {}
-BJD_CODE_MAP = {}
-REGION_NAME_MAP = {}
-REGION_UNIQUE_MAP = {}
-PROPERTY_UNIQUE_MAP = {}
-INFRA_UNIQUE_MAP = {}
-
-
-# *_FIELD_NAME_MAP
-# - key: filename.startswith(key)
-
-PRELOAD_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
- "rnaddrkor_": {
- "priority": -10,
- "file_extension": ".txt",
- "parser": None,
- "category": "rnaddrkor",
- },
- "jibun_rnaddrkor_": {
- "priority": -10,
- "file_extension": ".txt",
- "parser": None,
- "category": "jibun",
- },
- "AL_D164_": {
- "priority": -10,
- "file_extension": ".shp",
- "category": "AL_D164",
- },
-}
-
-REGION_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
- "법정동코드 전체": {
- "file_extension": ".txt",
- "delimiter": "\t",
- "category": "동네",
- "type": "main",
- "id": "법정동코드",
- "이름": "법정동명",
- "상태": "폐지여부",
- },
- "서울시 학원 교습소정보": {
- "priority": 10,
- "category": "동네",
- "type": "academy",
- "id": "학원지정번호",
- "이름": "학원명",
- "유형": "학원/교습소",
- "도로명주소": "도로명주소",
- "도로명상세주소": "도로명상세주소",
- "운영상태": "등록상태명",
- },
-}
-
-PROPERTY_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
- "AL_D164_": {
- "file_extension": ".shp",
- "category": "건물",
- "type": "main",
- },
- "서울시 공동주택 아파트 정보": {
- "priority": 10,
- "category": "건물",
- "type": "apartment",
- "id": "k-아파트코드",
- "이름": "k-아파트명",
- "위도": "좌표Y",
- "경도": "좌표X",
- "도로명주소": "kapt도로명주소",
- "시도": "주소(시도)k-apt주소split",
- "시군구": "주소(시군구)",
- "읍면동": "주소(읍면동)",
- "나머지주소": "나머지주소",
- "도로명": "주소(도로명)",
- "도로상세주소": "주소(도로상세주소)",
- "동수": "k-전체동수",
- "세대수": "k-전체세대수",
- "주차대수": "주차대수",
- },
-}
-
-INFRA_FIELD_NAME_MAP: dict[str, dict[str, Any]] = {
- "전국초중등학교위치표준데이터": {
- "category": "학교",
- "id": "학교ID",
- "이름": "학교명",
- "위도": "위도",
- "경도": "경도",
- "지번주소": "소재지지번주소",
- "도로명주소": "소재지도로명주소",
- "유형": "학교급구분",
- "운영상태": "운영상태",
- },
- "서울시 역사마스터 정보": {
- "category": "역사",
- "type": "main",
- "id": lambda row: f"01_{row['역사명']}",
- "이름": lambda row: row['역사명'].rstrip('역'),
- "위도": "위도",
- "경도": "경도",
- },
- "국토교통부_도시철도 전체노선": {
- "priority": 10,
- "category": "역사",
- "type": "lines",
- "id": ["권역", "역명"],
- "이름": lambda row: row['역명'].rstrip('역'),
- "노선": "노선명",
- },
- "서울시 응급실 위치 정보": {
- "category": "병원",
- "id": "기관ID",
- "이름": "기관명",
- "위도": "병원위도",
- "경도": "병원경도",
- "응급의료기관코드명": "응급의료기관코드명",
- },
- "생활_대규모점포_서울특별시": {
- "category": "마트",
- "id": "관리번호",
- "이름": "사업장명",
- "tm_x": "좌표정보(X)",
- "tm_y": "좌표정보(Y)",
- "영업상태명": "영업상태명",
- "업태구분명": "업태구분명",
- "점포구분명": "점포구분명",
- },
- "전국도시공원정보표준데이터": {
- "category": "공원",
- "id": "관리번호",
- "이름": "공원명",
- "위도": "위도",
- "경도": "경도",
- "지번주소": "소재지지번주소",
- "도로명주소": "소재지도로명주소",
- "면적": "공원면적",
- },
-}
-
-
-transformer = Transformer.from_crs("epsg:5179", "epsg:4326", always_xy=True)
-
def usage():
print("사용법: python insert_data.py")
print(" - data 폴더 내 *.csv 파일 데이터를 데이터베이스에 삽입합니다.")
-def string_rcut_and_rstrip(_value: str, suffix: str) -> tuple[str, bool]:
- value = _value.strip()
- if value.endswith(suffix):
- value = value[:-len(suffix)].strip()
- return value, _value != value
-
-def all_join_or_none(*args, sep=" "):
- if all(args):
- return sep.join(str(arg) for arg in args)
- return None
-
-
-def get_field_value(row: dict[str, Any], value: Any) -> str | None:
- if isinstance(value, str):
- return row.get(value, "").strip() or None
-
- if isinstance(value, Iterable) and all(isinstance(v, str) for v in value):
- return "_".join(str(row.get(key, "").strip()) for key in value) or None
-
- if callable(value):
- return value(row).strip() or None
-
-
-def get_coords_from_row(row: dict[str, Any], field_mapping: dict[str, Any]) -> tuple[float, float] | None:
- """
- 주어진 행에서 위도와 경도를 추출하여 float 형태로 반환합니다.
-
- Args:
- row (dict[str, Any]): CSV 파일의 한 행을 나타내는 딕셔너리
- field_mapping (dict[str, Any]): 위도와 경도 필드 이름이 매핑된 딕셔너리
-
- Returns:
- tuple[float, float] | None: (위도, 경도) 튜플 또는 좌표 정보를 찾을 수 없는 경우 None
- """
- lat = None
- lng = None
- try:
- lat = parse_float(row[field_mapping["위도"]])
- lng = parse_float(row[field_mapping["경도"]])
- if lat is None or lng is None: raise
- except:
- try:
- tm_x = parse_float(row[field_mapping["tm_x"]])
- tm_y = parse_float(row[field_mapping["tm_y"]])
- lng, lat = transformer.transform(tm_x, tm_y)
- except:
- pass
- if lat is None or lng is None:
- return None
- return lat, lng
-
-
-def normalize_sido(sido: str) -> str:
- sido = re.sub(r"^서울(시|특별시)?($|\s+)", "서울특별시 ", sido).strip()
- return sido
-
-
-def get_next_region_name(region_name: str) -> Generator[str, None, None]:
- maybe_gu = region_name.split()
- maybe_gu = maybe_gu[1] if len(maybe_gu) > 1 else None
-
- region_name = region_name.strip()
- yield region_name
-
- check1 = re.sub(r"[\d -]+$", "", region_name)
- yield check1
-
- check2 = re.sub(r"[\d·]+", "", check1)
- yield check2
-
- check3 = "상일동" if maybe_gu == "강동구" and check2 == "상동" else None
- if check3: yield check3
-
-
-def nomalize_apartment_address(name: str, address: str) -> str:
- strip = string_rcut_and_rstrip
- name, _ = strip(name, "아파트")
- address, _ = strip(address, "관리소")
- address, _ = strip(address, "관리사무소")
- address, _ = strip(address, "아파트")
- address, _ = strip(address, "번지")
- address, _ = strip(address, name)
- return address
-
-
-def clean_series_field(series, fill_val=""):
- return series.fillna(fill_val).astype(str).str.strip()
-
-def empty_series_to_nan(series):
- return series.astype(str).str.strip().replace("", np.nan)
-
-
-def parse_address_data(_df: pd.DataFrame) -> pd.DataFrame:
- print()
- print(f" - ✅ 원본 데이터 로드 완료 (총 {len(_df)}개 행)")
- print(f" - ⏳ 1. 데이터 제거 및 정형화 작업 중...")
-
- # 필수 데이터 결측치 제거 및 정형화
-
- df = _df.dropna(subset=["법정동코드"])
-
- tmp_df = df[[
- "도로명주소관리번호",
- "법정동코드",
- "시도명",
- "시군구명",
- "법정읍면동명",
- "법정리명",
- "산여부",
- "지번본번",
- "지번부번",
- "도로명코드",
- "도로명",
- "건물본번",
- "건물부번",
- ]].copy().apply(clean_series_field)
-
- tmp_df["법정읍면동명"] = tmp_df["법정읍면동명"].fillna("")
- tmp_df["법정리명"] = tmp_df["법정리명"].fillna("")
- tmp_df["산여부"] = tmp_df["산여부"].fillna("0")
- tmp_df["지번본번"] = tmp_df["지번본번"].fillna("0")
- tmp_df["지번부번"] = tmp_df["지번부번"].fillna("0")
- tmp_df["지번본번"] = tmp_df["지번본번"].str.zfill(4)
- tmp_df["지번부번"] = tmp_df["지번부번"].str.zfill(4)
-
- # 산여부 0과 1을 각각 1(일반 대지), 2(산)로 변환
- tmp_df["산여부"] = np.where(tmp_df["산여부"] == "0", "1", "2")
-
- # 시군구코드, 읍면동코드, 도로명번호 추출
- tmp_df["시군구코드"] = tmp_df["도로명코드"].str[:5]
- tmp_df["읍면동코드"] = tmp_df["도로명주소관리번호"].str[5:8]
- tmp_df["도로명번호"] = tmp_df["도로명코드"].str[5:]
-
- print(f" - ⏳ 2. pnu_code 컬럼 결합 연산 및 지번/도로명 주소 매핑 중...")
- tmp_df["pnu_code"] = tmp_df["법정동코드"] + tmp_df["산여부"] + tmp_df["지번본번"] + tmp_df["지번부번"]
-
- tmp_df["지번주소"] = (
- tmp_df["시도명"] + " " +
- tmp_df["시군구명"] + " " +
- tmp_df["법정읍면동명"] + " " +
- tmp_df["법정리명"] + " " +
- (
- tmp_df["지번본번"].str.lstrip("0") +
- "-" +
- tmp_df["지번부번"].str.lstrip("0")
- ).str.strip(" -")
- ).str.replace(r"\s+", " ", regex=True).str.strip()
-
- tmp_df["도로명주소"] = (
- tmp_df["시도명"] + " " +
- tmp_df["시군구명"] + " " +
- tmp_df["도로명"] + " " +
- (
- tmp_df["건물본번"].str.lstrip("0") +
- "-" +
- tmp_df["건물부번"].str.lstrip("0")
- ).str.strip(" -")
- )
-
- print(f" - ✅ 최종 데이터 개수: {len(tmp_df)}개")
- print(f" - ✅ 제거된 데이터 개수: {len(df) - len(tmp_df)}")
-
- res = df.copy()
- res["pnu_code"] = tmp_df["pnu_code"]
- res["시군구코드"] = tmp_df["시군구코드"]
- res["읍면동코드"] = tmp_df["읍면동코드"]
- res["도로명번호"] = tmp_df["도로명번호"]
- res["도로명주소"] = tmp_df["도로명주소"]
- res["지번주소"] = tmp_df["지번주소"]
- return res
-
-def parse_rnaddrkor(filepath: str, *_):
- rdf = read_pandas(
- filepath,
- names=[
- "도로명주소관리번호",
- "법정동코드",
- "시도명",
- "시군구명",
- "법정읍면동명",
- "법정리명",
- "산여부",
- "지번본번",
- "지번부번",
- "도로명코드",
- "도로명",
- "지하여부",
- "건물본번",
- "건물부번",
- "행정동코드",
- "행정동명",
- "기초구역번호",
- "이전도로명주소",
- "효력발생일",
- "공동주택구분",
- "이동사유코드",
- "건축물대장건물명",
- "시군구용건물명",
- "비고",
- ],
- )
-
- rdf = parse_address_data(rdf)
- print(f" - ⏳ 주소 변환을 위한 사전 매핑 중...")
- ADDRESS_MAP["rncode_to_rn"] = dict(zip(rdf["도로명코드"], rdf["도로명"]))
- DF["rnaddrkor"] = rdf
-
-def parse_jibun_rnaddrkor(filepath: str, *_):
- jdf = read_pandas(
- filepath,
- names=[
- "도로명주소관리번호",
- "법정동코드",
- "시도명",
- "시군구명",
- "법정읍면동명",
- "법정리명",
- "산여부",
- "지번본번",
- "지번부번",
- "도로명코드",
- "지하여부",
- "건물본번",
- "건물부번",
- "이동사유코드",
- ],
- )
-
- jdf["도로명"] = jdf["도로명코드"].map(ADDRESS_MAP["rncode_to_rn"])
- jdf = parse_address_data(jdf)
- DF["jibun"] = jdf
-
-def parse_AL_D164_shp(filepath: str, *_):
- gdf = read_shp_file(
- filepath,
- columns=[
- "GIS건물통합식별번호",
- "고유번호",
- "법정동코드",
- "법정동명",
- "특수지구분코드",
- "특수지구분명",
- "지번",
- "건물식별번호",
- "집합건물구분코드",
- "집합건물구분",
- "대장종류코드",
- "대장종류",
- "건물명",
- "상위건물식별번호",
- "도로명주소코드",
- "도로명주소읍면동리코드",
- "도로명주소지하코드",
- "도로명주소본번",
- "도로명주소부번",
- "건물동명",
- "건물주부구분코드",
- "건물주부구분",
- "건물대지면적(㎡)",
- "건물건축면적(㎡)",
- "건물연면적(㎡)",
- "용적율(%)",
- "건폐율(%)",
- "건축물구조코드",
- "건축물구조명",
- "건축물용도코드",
- "건축물용도명",
- "건물높이",
- "지상층수",
- "지하층수",
- "허가일자",
- "사용승인일자",
- "총주차수",
- "총주차장면적(㎡)",
- "데이터기준일자",
- "시군구코드",
- "geometry",
- ],
- )
-
- jdf = DF["jibun"]
- rdf = DF["rnaddrkor"]
- print()
- print(f" - ✅ 원본 데이터 로드 완료 (총 {len(gdf)}개 행)")
-
- # ==========================================
- # 1. 사람이 살 수 있는 주거용 건물 추출
- # ==========================================
- print(" - ⏳ 1. 주거용 건물(단독/공동주택 등) 필터링 중...")
-
- gdf["건축물용도코드"] = clean_series_field(gdf["건축물용도코드"])
- residential_mask = (
- gdf["건축물용도코드"].str.startswith("01") |
- gdf["건축물용도코드"].str.startswith("02") |
- gdf["건축물용도명"].str.contains("주택|아파트|빌라|오피스텔", na=False)
- )
- merged_df = gdf[residential_mask].copy()
-
- # ==========================================================
- # 2. 주소 매핑
- # ==========================================================
- print(f" - ⏳ 2. 주소 매핑용 룩업 사전 빌드 중...")
-
- # 0) 주소 매핑용 사전 빌드: 관리번호와 PNU 기준으로 각각 도로명주소/지번주소 매핑할 수 있는 사전 조립
- jdf_by_mgmt = jdf.drop_duplicates(subset=["도로명주소관리번호"])
- rdf_by_mgmt = rdf.drop_duplicates(subset=["도로명주소관리번호"])
-
- jdf_by_pnu = jdf.drop_duplicates(subset=["pnu_code"])
- rdf_by_pnu = rdf.drop_duplicates(subset=["pnu_code"])
-
- # 1) rnnum_to_rnaddr : 관리번호 -> 도로명주소 및 역매핑
- ADDRESS_MAP.setdefault("rnnum_to_rnaddr", {})
- ADDRESS_MAP.setdefault("rnaddr_to_rnnum", {})
- for zips in [
- zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["도로명주소관리번호"]),
- zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["도로명주소관리번호"]),
- ]:
- for addr, num in zips:
- ADDRESS_MAP["rnnum_to_rnaddr"].setdefault(num, addr)
- ADDRESS_MAP["rnaddr_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num)
-
- # 2) rnnum_to_jibun : 관리번호 -> 지번주소 및 역매핑
- ADDRESS_MAP.setdefault("rnnum_to_jibun", {})
- ADDRESS_MAP.setdefault("jibun_to_rnnum", {})
- for zips in [
- zip(jdf_by_pnu["지번주소"], jdf_by_pnu["도로명주소관리번호"]),
- zip(rdf_by_pnu["지번주소"], rdf_by_pnu["도로명주소관리번호"]),
- ]:
- for addr, num in zips:
- ADDRESS_MAP["rnnum_to_jibun"].setdefault(num, addr)
- ADDRESS_MAP["jibun_to_rnnum"].setdefault(addr.replace(" ", ""), []).append(num)
-
- # 3) pnu_to_rnaddr : PNU -> 도로명주소 및 역매핑
- ADDRESS_MAP.setdefault("pnu_to_rnaddr", {})
- ADDRESS_MAP.setdefault("rnaddr_to_pnu", {})
- for zips in [
- zip(rdf_by_mgmt["도로명주소"], rdf_by_mgmt["pnu_code"]),
- zip(jdf_by_mgmt["도로명주소"], jdf_by_mgmt["pnu_code"]),
- ]:
- for addr, pnu in zips:
- ADDRESS_MAP["pnu_to_rnaddr"].setdefault(pnu, addr)
- ADDRESS_MAP["rnaddr_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu)
-
- # 4) pnu_to_jibun : PNU -> 지번주소 및 역매핑
- ADDRESS_MAP.setdefault("pnu_to_jibun", {})
- ADDRESS_MAP.setdefault("jibun_to_pnu", {})
- for zips in [
- zip(jdf_by_pnu["지번주소"], jdf_by_pnu["pnu_code"]),
- zip(rdf_by_pnu["지번주소"], rdf_by_pnu["pnu_code"]),
- ]:
- for addr, pnu in zips:
- ADDRESS_MAP["pnu_to_jibun"].setdefault(pnu, addr)
- ADDRESS_MAP["jibun_to_pnu"].setdefault(addr.replace(" ", ""), []).append(pnu)
-
- print("✨ [SUCCESS] 데이터 우선순위 역전 사전(ADDRESS_MAP) 빌드 완료!")
-
- # ==========================================
- # 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립
- # ==========================================
- print(" - ⏳ 3. 도로명주소관리번호 추출 및 주소 매핑용 컬럼 조립 중...")
-
- tmp_df = merged_df[["고유번호", "도로명주소코드", "도로명주소읍면동리코드", "도로명주소지하코드", "도로명주소본번", "도로명주소부번"]].copy()
- tmp_df = tmp_df.dropna(subset=["도로명주소코드", "도로명주소읍면동리코드"], how="all")
-
- tmp_df["도로명주소코드"] = clean_series_field(tmp_df["도로명주소코드"])
- tmp_df["도로명주소읍면동리코드"] = clean_series_field(tmp_df["도로명주소읍면동리코드"])
- tmp_df["도로명주소지하코드"] = clean_series_field(tmp_df["도로명주소지하코드"], "0")
- tmp_df["도로명주소본번"] = clean_series_field(tmp_df["도로명주소본번"], "0")
- tmp_df["도로명주소부번"] = clean_series_field(tmp_df["도로명주소부번"], "0")
-
- tmp_df["시군구코드"] = tmp_df["도로명주소코드"].str[:5]
- tmp_df["읍면동코드"] = tmp_df["도로명주소읍면동리코드"].str[:3]
- tmp_df["읍면동일련번호"] = tmp_df["도로명주소읍면동리코드"].str[3:]
- tmp_df["도로명번호"] = tmp_df["도로명주소코드"].str[5:]
- tmp_df["도로명주소본번"] = tmp_df["도로명주소본번"].str.zfill(5)
- tmp_df["도로명주소부번"] = tmp_df["도로명주소부번"].str.zfill(5)
-
- tmp_df["도로명주소관리번호"] = tmp_df["시군구코드"] + tmp_df["읍면동코드"] + tmp_df["도로명번호"] + tmp_df["도로명주소지하코드"] + tmp_df["도로명주소본번"] + tmp_df["도로명주소부번"]
-
- # Left Join
- tmp_subset = tmp_df[["시군구코드", "읍면동코드", "읍면동일련번호", "도로명번호", "도로명주소관리번호"]]
- merged_df = merged_df.merge(tmp_subset, left_index=True, right_index=True, how="left")
-
- # ==========================================
- # 4. 국토부 GDF에 대장 주소 1차 수혈 (map 연산)
- # ==========================================
- print(" - ⏳ 4. 국토부 GDF에 대장 주소 매핑 중...")
- merged_df["도로명주소"] = merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_rnaddr"]).fillna(merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_rnaddr"]))
- merged_df["지번주소"] = merged_df["고유번호"].map(ADDRESS_MAP["pnu_to_jibun"]).fillna(merged_df["도로명주소관리번호"].map(ADDRESS_MAP["rnnum_to_jibun"]))
-
- # ==========================================
- # 5. 대장 미매칭 구역 대상 GDF 내장 정보 결합
- # ==========================================
- print(" - ⏳ 5. 대장 미매칭 유령 필지 대상 국토부 내장 정보 결합 및 예외 처리...")
-
- # 1) 지번주소 Fallback: 대장 주소가 끝까지 없으면 [법정동명 + 지번] 조합으로 강제 메꿈
- fallback_jibun = (merged_df["법정동명"] + " " + merged_df["지번"]).str.strip()
- merged_df["지번주소"] = np.where(
- merged_df["지번주소"].isna() | (merged_df["지번주소"] == "") | (merged_df["지번주소"] == "nan"),
- fallback_jibun,
- merged_df["지번주소"]
- )
-
- # 2) 도로명주소 Fallback: 대장 주소가 없으면 차선책으로 내장 건물명이라도 보존 (없으면 None)
- merged_df["도로명주소"] = np.where(
- merged_df["도로명주소"].isna() | (merged_df["도로명주소"] == "") | (merged_df["도로명주소"] == "nan"),
- np.where(merged_df["건물명"] != "", merged_df["건물명"], None),
- merged_df["도로명주소"]
- )
-
- # ==========================================
- # 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출
- # ==========================================
- print(" - ⏳ 6. 주소 분리 조립 및 필수/Nullable 데이터 최종 추출 중...")
-
- # 추후 region 관계설정 및 좌표를 point로 변환해야 함
- renames = {
- "고유번호": "source_id",
- "건축물용도명": "type",
- "건물명": "name",
- "지번주소": "land_lot_address",
- "도로명주소": "road_name_address",
- "법정동코드": "bjd_code",
- }
- res_df = merged_df[list(renames.keys())].rename(columns=renames)
-
- centroid_4326 = gpd.GeoSeries(merged_df.geometry.centroid, crs=merged_df.crs).to_crs(epsg=4326)
- res_df["lon"] = centroid_4326.x
- res_df["lat"] = centroid_4326.y
-
- # ====================================================
- # 7.: 19자리 고유번호(source_id) 기준 단지화 압축 (중복 제거)
- # ====================================================
- print(" - ⏳ 같은 아파트 단지(동별 데이터)를 하나의 대표 단지 데이터로 압축 중...")
- agg_rules = {
- "lon": "mean",
- "lat": "mean",
- "land_lot_address": "first",
- "road_name_address": "first", # 행안부 마스터 기준으로 완벽히 통일된 주소 채택
- "name": "first",
- "type": "first",
- "bjd_code": "first"
- }
-
- res_df = res_df.groupby("source_id", as_index=False).agg(agg_rules)
- res_df = res_df.replace({np.nan: None})
-
- GDF["AL_D164"] = res_df
-
-
-def parse_region(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
-
- if field_mapping["type"] == "main":
- if not name.startswith("서울"):
- return
-
- status = get_field_value(row, field_mapping["상태"])
- if status != "존재":
- return
-
- data = {
- "source_id": source_id,
- "name": name,
- "depth": name.count(" "),
- }
- REGION_NAME_MAP[name] = data
- BJD_CODE_MAP[source_id] = name
- BJD_CODE_MAP[name] = source_id
- return data
- elif field_mapping["type"] == "academy":
- status = get_field_value(row, field_mapping["운영상태"])
- if status != "개원":
- return
-
- try:
- academy_type = get_field_value(row, field_mapping["유형"])
- if academy_type == "교습소":
- return
-
- addr = get_field_value(row, field_mapping["도로명주소"])
- addr = normalize_sido(addr)
- si, gu = addr.split()[:2]
-
- addr_detail = get_field_value(row, field_mapping["도로명상세주소"])
- start_idx = addr_detail.find("(")
- if start_idx == -1: return
-
- details = [
- d.rstrip("층").strip()
- for a in addr_detail[start_idx:].split("(")
- for b in a.split(")")
- for c in b.replace(".", ",").split(",")
- for d in c.split(" ")
- if d.strip()
- ]
- context.setdefault("academy", []).append((si, gu, details))
- except:
- pass
-
-def post_parse_region(context: dict[str, Any]):
- academy = context.get("academy", [])
-
- for si, gu, details in academy:
- dong = None
- for item in details:
- checks = get_next_region_name(item)
- for curr in checks:
- if not curr: continue
- full_name = f"{si} {gu} {curr}"
- if full_name in REGION_NAME_MAP:
- dong = curr
- break
- if not dong: continue
- for key in [si, f"{si} {gu}", f"{si} {gu} {dong}"]:
- REGION_NAME_MAP.get(key, {}).setdefault("academy_count", 0)
- REGION_NAME_MAP[key]["academy_count"] += 1
-
-
-def parse_apartment(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- bjd_codes = context.setdefault("bjd_codes", {})
- if field_mapping["type"] == "main":
- gdf = GDF["AL_D164"]
- records = gdf.to_dict(orient="records")
- results = context.setdefault("results", {})
- for record in records:
- bjd_codes[record["source_id"]] = record["bjd_code"]
- results[record["source_id"]] = {
- "source_id": record["source_id"],
- "type": record["type"],
- "name": record["name"],
- "land_lot_address": record["land_lot_address"],
- "road_name_address": record["road_name_address"],
- "coordinates": (record["lat"], record["lon"]),
- }
- elif field_mapping["type"] == "apartment":
- name = get_field_value(row, field_mapping["이름"])
- apt_id = get_field_value(row, field_mapping["id"])
-
- rn_address = get_field_value(row, field_mapping["도로명주소"])
- sido = normalize_sido(get_field_value(row, field_mapping["시도"]) or "")
- sigungu = get_field_value(row, field_mapping["시군구"])
- dong = get_field_value(row, field_mapping["읍면동"])
- jibun_suffix = get_field_value(row, field_mapping["나머지주소"])
- road_name = get_field_value(row, field_mapping["도로명"])
- road_suffix = get_field_value(row, field_mapping["도로상세주소"])
- dong_count = parse_int(get_field_value(row, field_mapping["동수"]))
- household_count = parse_int(get_field_value(row, field_mapping["세대수"]))
- parking_count = parse_float(get_field_value(row, field_mapping["주차대수"]))
- parking_per_household = None if parking_count is None or household_count is None else parking_count / household_count if household_count else 0
-
- ll_address = nomalize_apartment_address(name, all_join_or_none(sido, sigungu, dong, jibun_suffix) or "") or None
- rn_address = nomalize_apartment_address(name, rn_address or all_join_or_none(sido, sigungu, road_name, road_suffix) or "") or None
-
- def extract_rnnum_from_kapt():
- rn_key = rn_address.replace(" ", "") if rn_address else None
- if rn_key in ADDRESS_MAP["rnaddr_to_rnnum"]:
- return ADDRESS_MAP["rnaddr_to_rnnum"][rn_key][0]
- ll_key = ll_address.replace(" ", "") if ll_address else None
- if ll_key in ADDRESS_MAP["jibun_to_rnnum"]:
- return ADDRESS_MAP["jibun_to_rnnum"][ll_key][0]
- return None # 양쪽 다 실패 시 안전하게 Null 리턴
- source_id = extract_rnnum_from_kapt()
-
- data = {
- "source_id": source_id or apt_id,
- "name": name,
- "land_lot_address": ll_address,
- "road_name_address": rn_address,
- "dong_count": dong_count,
- "household_count": household_count,
- "parking_count": parking_count,
- "parking_per_household": parking_per_household,
- }
-
- if not source_id:
- coords = get_coords_from_row(row, field_mapping)
- if not coords:
- return
-
- checks = get_next_region_name(dong)
- for curr in checks:
- if not curr: continue
- full_name = f"{sido} {sigungu} {curr}"
- if full_name in BJD_CODE_MAP:
- dong = curr
- break
-
- # BJD_CODE_MAP[full_name]: 항상 존재해야 함
- bjd_codes[apt_id] = BJD_CODE_MAP[full_name]
- return {
- **data,
- "coordinates": coords,
- }
- else:
- if ll_address: context.setdefault("extra_data", {})[ll_address.replace(" ", "")] = data
- if rn_address: context.setdefault("extra_data", {})[rn_address.replace(" ", "")] = data
-
-
-def parse_school(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
-
- coords = get_coords_from_row(row, field_mapping)
- if not coords:
- return
-
- status = get_field_value(row, field_mapping["운영상태"])
- if status != "운영":
- return
-
- address = get_field_value(row, field_mapping["도로명주소"]) or get_field_value(row, field_mapping["지번주소"])
- if not address.startswith("서울"):
- return
-
- school_type = get_field_value(row, field_mapping["유형"])
- infrastructure_type = "ELEMENTARY_SCHOOL" if school_type == "초등학교" else "MIDDLE_SCHOOL" if school_type == "중학교" else "HIGH_SCHOOL" if school_type == "고등학교" else None
- if not infrastructure_type:
- return
-
- # 학군?
- # 횡단보도 수?
-
- return {
- "type": infrastructure_type,
- "source_id": source_id,
- "name": name,
- "coordinates": coords,
- "details": None,
- }
-
-
-def parse_subway_station(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
-
- if field_mapping["type"] == "main":
- coords = get_coords_from_row(row, field_mapping)
- if coords:
- old_coords = context.setdefault("coords", {}).setdefault(source_id, {})
- count = old_coords.get("count")
- if count is None:
- old_coords["count"] = 1
- old_coords["lat"] = coords[0]
- old_coords["lng"] = coords[1]
- else:
- count += 1
- old_coords["count"] += count
- old_coords["lat"] += (coords[0] - old_coords["lat"]) / count
- old_coords["lng"] += (coords[1] - old_coords["lng"]) / count
-
- return {
- "type": "SUBWAY_STATION",
- "source_id": source_id,
- "name": name,
- }
- elif field_mapping["type"] == "lines":
- result = context.get("results", {}).get(source_id)
- if not result: return
- line = get_field_value(row, field_mapping["노선"])
- result.setdefault("details", {}).setdefault("lines", []).append(line)
-
-def post_parse_subway_station(context: dict[str, Any]):
- all_coords = context.get("coords", {})
- for source_id in all_coords:
- coords = all_coords[source_id]
- if not coords: continue
- context.get("results", {}).get(source_id, {})["coordinates"] = (coords["lat"], coords["lng"])
-
-
-def parse_large_hospital(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
-
- coords = get_coords_from_row(row, field_mapping)
- if not coords:
- return
-
- code = get_field_value(row, field_mapping["응급의료기관코드명"])
-
- details = {
- "응급의료기관코드명": code,
- }
-
- infrastructure_type = "LARGE_HOSPITAL"
- return {
- "type": infrastructure_type,
- "source_id": source_id,
- "name": name,
- "coordinates": coords,
- "details": details,
- }
-
-
-def parse_large_supermarket(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
- coords = get_coords_from_row(row, field_mapping)
- if not coords:
- return
-
- status = get_field_value(row, field_mapping["영업상태명"])
- if status != "영업/정상":
- return
-
- store_type = get_field_value(row, field_mapping["점포구분명"])
- if store_type not in ["대규모점포", "준대규모점포"]:
- return
-
- biz_type = get_field_value(row, field_mapping["업태구분명"])
-
- infrastructure_type = "LARGE_SUPERMARKET"
- return {
- "type": infrastructure_type,
- "source_id": source_id,
- "name": name,
- "coordinates": coords,
- "details": {
- "업태구분명": biz_type,
- "점포구분명": store_type,
- },
- }
-
-
-def parse_park(row: dict[str, Any], field_mapping: dict[str, Any], context: dict[str, Any]):
- name = get_field_value(row, field_mapping["이름"])
- source_id = get_field_value(row, field_mapping["id"])
-
- coords = get_coords_from_row(row, field_mapping)
- if not coords:
- return
-
- address = get_field_value(row, field_mapping["지번주소"]) or get_field_value(row, field_mapping["도로명주소"])
- if not address.startswith("서울"):
- return
-
- try:
- park_area = get_field_value(row, field_mapping["면적"])
- except:
- park_area = None
-
- infrastructure_type = "PARK"
- return {
- "type": infrastructure_type,
- "source_id": source_id,
- "name": name,
- "coordinates": coords,
- "details": {
- "면적": park_area,
- },
- }
-
-
-def connect_relationships(contexts: dict[str, Any]):
- # region self relationship
- regions = contexts.get("동네", {}).get("instances", {})
- for source_id in regions:
- ins = regions[source_id]
- name = ins.name
- if " " in name:
- parent_name = " ".join(name.split(" ")[:-1])
- parent_source_id = REGION_NAME_MAP[parent_name]["source_id"]
- parent_ins = regions[parent_source_id]
- ins.parent = parent_ins
-
- # property - region relationship
- context = contexts.get("건물", {})
- bjd_codes = context.get("bjd_codes", {})
- properties = context.get("instances", {})
- extra_data = context.get("extra_data", {})
- for source_id in properties:
- ins = properties[source_id]
-
- bjd_code = bjd_codes[source_id]
- ins.region = regions[bjd_code]
-
- keys = [
- ins.land_lot_address,
- ins.road_name_address,
- ]
- extra = None
- for key in keys:
- key = key.replace(" ", "").strip() if key else None
- if not key: continue
- if key in extra_data:
- extra = extra_data[key]
- break
- if not extra: continue
- if not ins.name: ins.name = extra.get("name")
- if not ins.land_lot_address: ins.land_lot_address = extra.get("land_lot_address")
- if not ins.road_name_address: ins.road_name_address = extra.get("road_name_address")
- ins.dong_count = extra.get("dong_count")
- ins.household_count = extra.get("household_count")
- ins.parking_count = extra.get("parking_count")
- ins.parking_per_household = extra.get("parking_per_household")
-
-
-def _main(db: Session, target_files: list[str]):
- print(f"데이터베이스에서 기존 데이터 조회 중...")
- REGION_UNIQUE_MAP.update({x.source_id: x for x in db.query(Region).all()})
- PROPERTY_UNIQUE_MAP.update({x.source_id: x for x in db.query(Property).all()})
- INFRA_UNIQUE_MAP.update({(x.type.value, x.source_id): x for x in db.query(Infrastructure).all()})
- MAP_ROUTER = {
- Region: REGION_UNIQUE_MAP,
- Property: PROPERTY_UNIQUE_MAP,
- Infrastructure: INFRA_UNIQUE_MAP
- }
- def get_model_instance(model_cls, result: dict[str, Any]):
- target_map = MAP_ROUTER.get(model_cls, {})
-
- if model_cls is Region or model_cls is Property:
- key = result["source_id"]
- elif model_cls is Infrastructure:
- key = (result.get("type"), result.get("source_id"))
-
- return target_map.pop(key, None)
-
- print(f"데이터 처리 중...")
- contexts: dict[str, Any] = {}
- map_items = [
- item
- for field_map in [
- PRELOAD_FIELD_NAME_MAP,
- REGION_FIELD_NAME_MAP,
- PROPERTY_FIELD_NAME_MAP,
- INFRA_FIELD_NAME_MAP,
- ]
- for item in field_map.items()
- ]
- priorities = {}
- for k, v in map_items:
- p = v.get("priority", 0)
- priorities.setdefault(p, []).append((k, v))
-
- filepath_and_names = [(file, Path(file).stem) for file in target_files]
- for p in sorted(priorities):
- for key, field_mapping in priorities[p]:
- file_extension = field_mapping.get("file_extension", ".csv")
- filtered_files = filter(lambda x: x[1].startswith(key) and x[0].endswith(file_extension), filepath_and_names)
- for filepath, filename in filtered_files:
- is_iterable = False
- if file_extension != ".shp":
- parser = field_mapping.get("parser", "csv")
- if parser == "csv":
- is_iterable = True
- header = field_mapping.get("header", True)
- fieldnames = field_mapping.get("fieldnames")
- delimiter = field_mapping.get("delimiter", ",")
- rows = read_csv_file(
- filepath,
- header=header,
- fieldnames=fieldnames,
- delimiter=delimiter,
- )
- category = field_mapping["category"]
- match category:
- case "AL_D164":
- callback = parse_AL_D164_shp
- case "rnaddrkor":
- callback = parse_rnaddrkor
- case "jibun":
- callback = parse_jibun_rnaddrkor
- case "동네":
- callback = parse_region
- case "건물":
- callback = parse_apartment
- case "학교":
- callback = parse_school
- case "역사":
- callback = parse_subway_station
- case "병원":
- callback = parse_large_hospital
- case "마트":
- callback = parse_large_supermarket
- case "공원":
- callback = parse_park
- case _:
- continue
-
- prefix = f"[{file_extension.strip('.')}] [{filename}]"
- context = contexts.setdefault(category, {})
- if is_iterable:
- def run(idx: int, row: dict[str, Any]):
- result = callback(row, field_mapping, context)
- if not result: return
-
- source_id = get_field_value(row, field_mapping["id"])
- context.setdefault("results", {})[source_id] = result
- run_with_progress(rows, prefix, run, interval=PROGRESS_PRINT)
- else:
- def run(idx: int, filepath: str):
- callback(filepath, field_mapping, context)
- run_with_progress([filepath], prefix, run, interval=PROGRESS_PRINT)
-
- print(f"데이터 정리 중...")
- for category in contexts:
- print(f" - {category}")
- context = contexts[category]
- match category:
- case "동네":
- callback = post_parse_region
- case "역사":
- callback = post_parse_subway_station
- case _:
- continue
- callback(context)
-
- print(f"데이터 삽입 중...")
- for category in contexts:
- match category:
- case "동네":
- Model = Region
- case "건물":
- Model = Property
- case _:
- Model = Infrastructure
-
- context = contexts[category]
- results = context.get("results") or {}
- items = list(results.values())
- def run(idx: int, result):
- if not result: return
- ins = get_model_instance(Model, result)
- if ins:
- for key, val in result.items():
- setattr(ins, key, val)
- else:
- ins = Model(**result)
- db.add(ins)
- ins.deleted_at = None
- context.setdefault("instances", {})[result["source_id"]] = ins
- run_with_progress(items, f"[{category}]", run, interval=PROGRESS_PRINT)
-
- print(f"테이블 관계 설정 중...")
- connect_relationships(contexts)
-
- print(f"데이터 soft delete 처리 중...")
- for model, curr_map in MAP_ROUTER.items():
- items = list(curr_map.values())
- def run(idx: int, ins):
- ins.deleted_at = ins.deleted_at or func.now()
- run_with_progress(items, f"[{model.__tablename__}]", run, interval=PROGRESS_PRINT)
-
-
-def main(directory_path: str | None = None):
- """
- 디렉토리 내의 CSV 파일을 읽어 데이터를 데이터베이스에 삽입하는 메인 함수입니다.
-
- Args:
- directory_path (str): CSV 파일이 있는 디렉토리 경로
- Retruns:
- None
- """
-
- folder = Path(directory_path) if directory_path else Path(__file__).resolve().parent.parent / "data"
-
- Base.metadata.create_all(bind=engine)
- db = SessionLocal()
- try:
- if not folder.is_dir():
- print(f"폴더가 아닙니다: {str(folder)}")
- return
-
- files = list(get_files(
- str(folder),
- extensions=[".csv", ".txt", ".shp"],
- recursive=True,
- ))
-
- _main(db, files)
- except Exception as e:
- print()
- print(f"데이터 삽입 중 오류가 발생했습니다. 롤백을 진행합니다. ({e})")
- db.rollback()
- raise e
- else:
- print("데이터 삽입이 성공적으로 완료되었습니다. 커밋을 진행합니다.")
- db.commit()
- finally:
- db.close()
-
-
if __name__ == "__main__":
if "-h" in sys.argv or "--help" in sys.argv:
usage()
sys.exit(0)
- elif len(sys.argv) != 1:
+ elif len(sys.argv) > 2:
usage()
sys.exit(1)
- else:
- main()
+
+ from app.manage import data
+ data.insert()
diff --git a/scripts/insert_seeds.py b/scripts/insert_seeds.py
index c763c7a..3737ee6 100755
--- a/scripts/insert_seeds.py
+++ b/scripts/insert_seeds.py
@@ -32,9 +32,9 @@ def usage():
usage()
sys.exit(1)
- from app.seeds.insert import run as insert_seeds
+ from app.manage import seeds
try:
- insert_seeds(*args)
+ seeds.insert(*args)
except Exception as e:
print("시드 데이터 삽입 중 오류가 발생했습니다.")
raise e
diff --git a/tests/test_manage.py b/tests/test_manage.py
new file mode 100644
index 0000000..944d3d7
--- /dev/null
+++ b/tests/test_manage.py
@@ -0,0 +1,339 @@
+"""관리용 라우터 및 주요 실행 함수 단위 테스트 모듈."""
+
+import os
+from unittest.mock import MagicMock, patch, ANY
+import pytest
+from fastapi import status
+
+
+# ==============================================================================
+# 1. API 엔드포인트 테스트 (API Router Tests)
+# ==============================================================================
+
+def test_create_tables_endpoint_success(client):
+ """POST /manage/tables 테이블 생성 성공 테스트"""
+ with patch("app.manage.tables.router.create") as mock_create:
+ response = client.post("/manage/tables")
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == {"success": True}
+ mock_create.assert_called_once()
+
+
+def test_create_tables_endpoint_failure(client):
+ """POST /manage/tables 테이블 생성 실패 테스트 (try-except 내장 처리로 201 반환)"""
+ with patch("app.manage.tables.router.create", side_effect=Exception("Database Error")) as mock_create:
+ response = client.post("/manage/tables")
+ assert response.status_code == status.HTTP_201_CREATED
+ assert response.json() == {"success": False}
+ mock_create.assert_called_once()
+
+
+def test_drop_tables_endpoint_success(client):
+ """DELETE /manage/tables 테이블 삭제 성공 테스트"""
+ with patch("app.manage.tables.router.drop") as mock_drop:
+ response = client.delete("/manage/tables")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"success": True}
+ mock_drop.assert_called_once()
+
+
+def test_drop_tables_endpoint_failure(client):
+ """DELETE /manage/tables 테이블 삭제 실패 테스트"""
+ with patch("app.manage.tables.router.drop", side_effect=Exception("Database Error")) as mock_drop:
+ response = client.delete("/manage/tables")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"success": False}
+ mock_drop.assert_called_once()
+
+
+def test_download_data_endpoint(client):
+ """POST /manage/data/sources 소스 다운로드 백그라운드 요청 테스트"""
+ with patch("app.manage.data.router.download") as mock_download:
+ response = client.post("/manage/data/sources")
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert response.json() == {"requested": True}
+ mock_download.assert_called_once_with(silent=False)
+
+
+def test_download_data_status_endpoint(client):
+ """GET /manage/data/sources/status 다운로드 상태 및 버전 조회 테스트"""
+ with patch("app.manage.data.router.get_current_version", return_value="v1.0.0") as mock_curr, \
+ patch("app.manage.data.router.get_downloading_version", return_value="v1.1.0") as mock_down:
+
+ response = client.get("/manage/data/sources/status")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "curr_version": "v1.0.0",
+ "downloading_version": "v1.1.0"
+ }
+ mock_curr.assert_called_once()
+ mock_down.assert_called_once()
+
+
+def test_insert_data_endpoint(client):
+ """PATCH /manage/data 데이터 삽입 백그라운드 요청 테스트"""
+ with patch("app.manage.data.router.insert") as mock_insert:
+ response = client.patch("/manage/data")
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert response.json() == {"requested": True}
+ mock_insert.assert_called_once_with(silent=False)
+
+
+def test_check_data_status_endpoint(client, mock_db):
+ """GET /manage/data/status 데이터 삽입 유무 및 테이블 행 존재 상태 확인 테스트"""
+ mock_result = MagicMock()
+ mock_result.tuples.return_value.first.return_value = (True, False, True)
+ mock_db.execute.return_value = mock_result
+
+ response = client.get("/manage/data/status")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "has_region": True,
+ "has_property": False,
+ "has_infrastructure": True,
+ }
+
+
+def test_generate_seeds_endpoint(client):
+ """POST /manage/seeds 시드 데이터 생성 백그라운드 요청 테스트"""
+ with patch("app.manage.seeds.router.insert_seeds") as mock_insert_seeds:
+ response = client.post("/manage/seeds", json={"recommendations": 25, "users": 10})
+ assert response.status_code == status.HTTP_202_ACCEPTED
+ assert response.json() == {"requested": True}
+ mock_insert_seeds.assert_called_once_with(
+ num_recommendations=25,
+ num_users=10,
+ silent=True
+ )
+
+
+def test_delete_seeds_endpoint_success(client):
+ """DELETE /manage/seeds 시드 데이터 삭제 성공 테스트"""
+ with patch("app.manage.seeds.router.drop_seeds") as mock_drop_seeds:
+ response = client.delete("/manage/seeds")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"success": True}
+ mock_drop_seeds.assert_called_once()
+
+
+def test_delete_seeds_endpoint_failure(client):
+ """DELETE /manage/seeds 시드 데이터 삭제 실패 테스트"""
+ with patch("app.manage.seeds.router.drop_seeds", side_effect=Exception("Database Error")) as mock_drop_seeds:
+ response = client.delete("/manage/seeds")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {"success": False}
+ mock_drop_seeds.assert_called_once()
+
+
+def test_check_seed_status_endpoint(client, mock_db):
+ """GET /manage/seeds/status 시드 데이터 생성 상태 개수 조회 테스트"""
+ mock_result = MagicMock()
+ mock_result.scalars.return_value.all.return_value = [100, 250]
+ mock_db.execute.return_value = mock_result
+
+ response = client.get("/manage/seeds/status")
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json() == {
+ "total_users": 100,
+ "total_recommendations": 250
+ }
+
+
+# ==============================================================================
+# 2. 메인 실행 함수(Main Run Functions) 테스트
+# ==============================================================================
+
+def test_run_create_tables():
+ """tables/create.py run() 함수 호출 테스트"""
+ from app.manage.tables.create import run as run_create
+ with patch("app.manage.tables.create.Base.metadata.create_all") as mock_create_all:
+ run_create()
+ mock_create_all.assert_called_once()
+
+
+def test_run_create_tables_error():
+ """tables/create.py run() 함수 예외 발생 테스트"""
+ from app.manage.tables.create import run as run_create
+ with patch("app.manage.tables.create.Base.metadata.create_all", side_effect=Exception("Metadata Error")):
+ with pytest.raises(Exception) as exc_info:
+ run_create()
+ assert "Metadata Error" in str(exc_info.value)
+
+
+def test_run_drop_tables():
+ """tables/drop.py run() 함수 호출 테스트"""
+ from app.manage.tables.drop import run as run_drop
+ with patch("app.manage.tables.drop.Base.metadata.drop_all") as mock_drop_all:
+ run_drop()
+ mock_drop_all.assert_called_once()
+
+
+def test_run_drop_tables_error():
+ """tables/drop.py run() 함수 예외 발생 테스트"""
+ from app.manage.tables.drop import run as run_drop
+ with patch("app.manage.tables.drop.Base.metadata.drop_all", side_effect=Exception("Metadata Error")):
+ with pytest.raises(Exception) as exc_info:
+ run_drop()
+ assert "Metadata Error" in str(exc_info.value)
+
+
+def test_run_download_no_update():
+ """data/download.py run() - 현재 버전과 최신 버전이 동일하여 다운로드하지 않는 경우"""
+ from app.manage.data.download import run as run_download
+
+ urls_mock = {"version": "http://ver", "google drive": "http://drive"}
+
+ with patch("app.manage.data.download.read_url_file", return_value=urls_mock) as mock_read_urls, \
+ patch("app.manage.data.download.read_file") as mock_read_file, \
+ patch("app.manage.data.download.download_file") as mock_download_file, \
+ patch("app.manage.data.download.download_folder") as mock_download_folder, \
+ patch("app.manage.data.download.write_file") as mock_write_file, \
+ patch("app.manage.data.download.move_folder") as mock_move_folder, \
+ patch("app.manage.data.download.os.remove") as mock_remove:
+
+ # read_file 첫 호출(curr_version): "v1.0", 두 번째 호출(new_version): "v1.0"
+ mock_read_file.side_effect = ["v1.0", "v1.0"]
+
+ result = run_download(silent=True)
+
+ assert result is False
+ mock_read_urls.assert_called_once()
+ mock_download_file.assert_called_once_with(url="http://ver", output=ANY, silent=True)
+ # 버전이 동일하므로 다운로드 폴더 및 파일 쓰기가 호출되면 안 됨
+ mock_download_folder.assert_not_called()
+ mock_write_file.assert_not_called()
+
+
+def test_run_download_success():
+ """data/download.py run() - 새 버전이 출시되어 정상 다운로드 및 데이터 갱신 완료되는 경우"""
+ from app.manage.data.download import run as run_download
+
+ urls_mock = {"version": "http://ver", "google drive": "http://drive"}
+
+ with patch("app.manage.data.download.read_url_file", return_value=urls_mock) as mock_read_urls, \
+ patch("app.manage.data.download.read_file") as mock_read_file, \
+ patch("app.manage.data.download.download_file") as mock_download_file, \
+ patch("app.manage.data.download.download_folder") as mock_download_folder, \
+ patch("app.manage.data.download.write_file") as mock_write_file, \
+ patch("app.manage.data.download.move_folder") as mock_move_folder, \
+ patch("app.manage.data.download.os.remove") as mock_remove:
+
+ # read_file 첫 호출(curr_version): "v1.0", 두 번째 호출(new_version): "v2.0"
+ mock_read_file.side_effect = ["v1.0", "v2.0"]
+
+ result = run_download(silent=True)
+
+ assert result is True
+ mock_read_urls.assert_called_once()
+ mock_download_file.assert_called_once_with(url="http://ver", output=ANY, silent=True)
+ mock_write_file.assert_called_once_with(ANY, "v2.0")
+ mock_download_folder.assert_called_once_with(url="http://drive", output=ANY, silent=True)
+ assert mock_move_folder.call_count == 2
+ assert mock_remove.call_count == 2
+
+
+def test_run_insert_data_success():
+ """data/insert.py run() - 데이터 파일 정상 로드 및 DB 삽입 트랜잭션 정상 완료 테스트"""
+ from app.manage.data.insert import run as run_insert
+
+ mock_session = MagicMock()
+
+ with patch("app.manage.data.insert.Base.metadata.create_all") as mock_create_all, \
+ patch("app.manage.data.insert.SessionLocal", return_value=mock_session) as mock_session_local, \
+ patch("app.manage.data.insert.Path.is_dir", return_value=True) as mock_is_dir, \
+ patch("app.manage.data.insert.get_files", return_value=["file1.csv"]) as mock_get_files, \
+ patch("app.manage.data.insert._run") as mock_internal_run:
+
+ run_insert(silent=True)
+
+ mock_create_all.assert_called_once()
+ mock_session_local.assert_called_once()
+ mock_internal_run.assert_called_once_with(mock_session, ["file1.csv"], silent=True)
+ mock_session.commit.assert_called_once()
+ mock_session.close.assert_called_once()
+
+
+def test_run_insert_data_failure():
+ """data/insert.py run() - 삽입 도중 에러가 발생하여 롤백 및 세션 종료가 처리되는 테스트"""
+ from app.manage.data.insert import run as run_insert
+
+ mock_session = MagicMock()
+
+ with patch("app.manage.data.insert.Base.metadata.create_all") as mock_create_all, \
+ patch("app.manage.data.insert.SessionLocal", return_value=mock_session) as mock_session_local, \
+ patch("app.manage.data.insert.Path.is_dir", return_value=True) as mock_is_dir, \
+ patch("app.manage.data.insert.get_files", return_value=["file1.csv"]) as mock_get_files, \
+ patch("app.manage.data.insert._run", side_effect=Exception("IO Error")) as mock_internal_run:
+
+ with pytest.raises(Exception) as exc_info:
+ run_insert(silent=True)
+
+ assert "IO Error" in str(exc_info.value)
+ mock_session.rollback.assert_called_once()
+ mock_session.close.assert_called_once()
+
+
+def test_run_insert_seeds_success():
+ """seeds/insert.py run() - 랜덤 시드 데이터 정상 추가 완료 테스트"""
+ from app.manage.seeds.insert import run as run_insert_seeds
+
+ mock_session = MagicMock()
+
+ with patch("app.manage.seeds.insert.SessionLocal", return_value=mock_session) as mock_session_local, \
+ patch("app.manage.seeds.insert._run", return_value=50) as mock_internal_run:
+
+ total = run_insert_seeds(num_recommendations=20, num_users=5, silent=True)
+
+ assert total == 50
+ mock_session_local.assert_called_once()
+ mock_internal_run.assert_called_once_with(mock_session, 20, 5, silent=True)
+ mock_session.commit.assert_called_once()
+ mock_session.close.assert_called_once()
+
+
+def test_run_insert_seeds_failure():
+ """seeds/insert.py run() - 시드 데이터 추가 중 예외 발생 시 롤백 및 세션 종료 테스트"""
+ from app.manage.seeds.insert import run as run_insert_seeds
+
+ mock_session = MagicMock()
+
+ with patch("app.manage.seeds.insert.SessionLocal", return_value=mock_session) as mock_session_local, \
+ patch("app.manage.seeds.insert._run", side_effect=Exception("Seed Error")) as mock_internal_run:
+
+ with pytest.raises(Exception) as exc_info:
+ run_insert_seeds(silent=True)
+
+ assert "Seed Error" in str(exc_info.value)
+ mock_session.rollback.assert_called_once()
+ mock_session.close.assert_called_once()
+
+
+def test_run_drop_seeds_success():
+ """seeds/drop.py run() - 시드 데이터 정상 일괄 삭제 완료 테스트"""
+ from app.manage.seeds.drop import run as run_drop_seeds
+
+ mock_session = MagicMock()
+
+ with patch("app.manage.seeds.drop.SessionLocal", return_value=mock_session) as mock_session_local:
+ run_drop_seeds()
+
+ mock_session_local.assert_called_once()
+ assert mock_session.query.call_count == 2
+ mock_session.commit.assert_called_once()
+ mock_session.close.assert_called_once()
+
+
+def test_run_drop_seeds_failure():
+ """seeds/drop.py run() - 삭제 진행 중 오류 시 롤백 및 세션 정상 종료 테스트"""
+ from app.manage.seeds.drop import run as run_drop_seeds
+
+ mock_session = MagicMock()
+ mock_session.query.side_effect = Exception("Drop Query Failed")
+
+ with patch("app.manage.seeds.drop.SessionLocal", return_value=mock_session) as mock_session_local:
+ with pytest.raises(Exception) as exc_info:
+ run_drop_seeds()
+
+ assert "Drop Query Failed" in str(exc_info.value)
+ mock_session.rollback.assert_called_once()
+ mock_session.close.assert_called_once()
diff --git a/tests/test_mode.py b/tests/test_mode.py
index 2b51a2f..eb8842f 100644
--- a/tests/test_mode.py
+++ b/tests/test_mode.py
@@ -26,8 +26,8 @@ def test_dev_only_routes_blocked_in_production(client) -> None:
response_scalar = client.get("/scalar")
assert response_scalar.status_code == 404
- response_seeds = client.get("/seeds")
- assert response_seeds.status_code == 404
+ response_manage = client.get("/manage/seeds")
+ assert response_manage.status_code == 404
response_guest = client.get("/auth/guest")
assert response_guest.status_code == 404
@@ -50,7 +50,8 @@ def test_dev_only_routes_allowed_in_development() -> None:
assert response_scalar.status_code == 200
- # 2. 접근 허용 (존재하지 않는 메소드 요청으로 405 Method Not Allowed 반환하여 라우터 마운트 상태 확인)
-
- response_seeds = client.get("/seeds")
- assert response_seeds.status_code == 405
+ # 2. 접근 허용
+
+ # 존재하지 않는 메소드 요청으로 405 Method Not Allowed 반환하여 라우터 마운트 상태 확인
+ response_manage = client.get("/manage/seeds")
+ assert response_manage.status_code == 405